/** * IPC Handlers * Registers all IPC handlers for main-renderer communication */ import { ipcMain, BrowserWindow, shell, dialog, app, nativeImage } from 'electron'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, extname, basename, resolve, sep, relative } from 'node:path'; import crypto from 'node:crypto'; import { GatewayManager } from '../gateway/manager'; import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub'; import { type ProviderConfig, } from '../utils/secure-storage'; import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir, ensureDir, expandPath } from '../utils/paths'; import { getOpenClawCliCommand } from '../utils/openclaw-cli'; import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../utils/store'; import { saveProviderKeyToOpenClaw, removeProviderFromOpenClaw, } from '../utils/openclaw-auth'; import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; import { buildOpenClawControlUiUrl } from '../utils/openclaw-control-ui'; import { logger } from '../utils/logger'; import { resolveAgentIdFromChannel } from '../utils/agent-config'; import { resolveAccountIdFromSessionHistory } from '../utils/session-util'; import { removeSessionEntry, resolveSessionTranscriptPath, sweepSessionArtefacts, } from '../utils/session-files'; import { saveChannelConfig, getChannelConfig, getChannelFormValues, deleteChannelConfig, listConfiguredChannels, setChannelEnabled, validateChannelConfig, validateChannelCredentials, } from '../utils/channel-config'; import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias'; import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup'; import { ensureDingTalkPluginInstalled, ensureFeishuPluginInstalled, ensureWeComPluginInstalled, } from '../utils/plugin-install'; import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config'; import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { getProviderConfig } from '../utils/provider-registry'; import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth'; import { applyProxySettings } from './proxy'; import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getRecentTokenUsageHistory } from '../utils/token-usage'; import { getProviderService } from '../services/providers/provider-service'; import { getOpenClawProviderKey, syncDefaultProviderToRuntime, syncDeletedProviderApiKeyToRuntime, syncDeletedProviderToRuntime, syncProviderApiKeyToRuntime, syncSavedProviderToRuntime, syncUpdatedProviderToRuntime, } from '../services/providers/provider-runtime-sync'; import { validateApiKeyWithProvider } from '../services/providers/provider-validation'; import { appUpdater } from './updater'; import { GatewayRpcBackpressure } from '../gateway/rpc-backpressure'; import { registerHostApiProxyHandlers } from './ipc/host-api-proxy'; import { isLaunchAtStartupKey, isProxyKey, mapAppErrorCode, type AppRequest, type AppResponse, } from './ipc/request-helpers'; const gatewayRpcBackpressure = new GatewayRpcBackpressure(); /** * Register all IPC handlers */ export function registerIpcHandlers( gatewayManager: GatewayManager, clawHubService: ClawHubService, mainWindow: BrowserWindow ): void { // Unified request protocol (non-breaking: legacy channels remain available) registerUnifiedRequestHandlers(gatewayManager); // Host API proxy handlers registerHostApiProxyHandlers(); // Gateway handlers registerGatewayHandlers(gatewayManager, mainWindow); // ClawHub handlers registerClawHubHandlers(clawHubService); // OpenClaw handlers registerOpenClawHandlers(gatewayManager); // Provider handlers registerProviderHandlers(gatewayManager); // Shell handlers registerShellHandlers(); // Dialog handlers registerDialogHandlers(); // Session handlers registerSessionHandlers(); // App handlers registerAppHandlers(); // Settings handlers registerSettingsHandlers(gatewayManager); // UV handlers registerUvHandlers(); // Log handlers (for UI to read gateway/app logs) registerLogHandlers(); // Usage handlers registerUsageHandlers(); // Skill config handlers (direct file access, no Gateway RPC) registerSkillConfigHandlers(); // Cron task handlers (proxy to Gateway RPC) registerCronHandlers(gatewayManager); // Window control handlers (for custom title bar on Windows/Linux) registerWindowHandlers(mainWindow); // WhatsApp handlers registerWhatsAppHandlers(mainWindow); // Device OAuth handlers (Code Plan) registerDeviceOAuthHandlers(mainWindow); // File staging handlers (upload/send separation) registerFileHandlers(); // File preview handlers (sandboxed read/write/list for inline viewer) registerFilePreviewHandlers(); } function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { const providerService = getProviderService(); const handleProxySettingsChange = async () => { const settings = await getAllSettings(); await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false }); await applyProxySettings(settings); if (gatewayManager.getStatus().state === 'running') { await gatewayManager.restart(); } }; ipcMain.handle('app:request', async (_, request: AppRequest): Promise => { if (!request || typeof request.module !== 'string' || typeof request.action !== 'string') { return { id: request?.id, ok: false, error: { code: 'VALIDATION', message: 'Invalid app request format' }, }; } try { let data: unknown; switch (request.module) { case 'app': { if (request.action === 'version') data = app.getVersion(); else if (request.action === 'name') data = app.getName(); else if (request.action === 'platform') data = process.platform; else { return { id: request.id, ok: false, error: { code: 'UNSUPPORTED', message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, }, }; } break; } case 'provider': { if (request.action === 'list') { data = await providerService.listLegacyProvidersWithKeyInfo(); break; } if (request.action === 'get') { const payload = request.payload as { providerId?: string } | string | undefined; const providerId = typeof payload === 'string' ? payload : payload?.providerId; if (!providerId) throw new Error('Invalid provider.get payload'); data = await providerService.getLegacyProvider(providerId); break; } if (request.action === 'getDefault') { data = await providerService.getDefaultLegacyProvider(); break; } if (request.action === 'hasApiKey') { const payload = request.payload as { providerId?: string } | string | undefined; const providerId = typeof payload === 'string' ? payload : payload?.providerId; if (!providerId) throw new Error('Invalid provider.hasApiKey payload'); data = await providerService.hasLegacyProviderApiKey(providerId); break; } if (request.action === 'getApiKey') { const payload = request.payload as { providerId?: string } | string | undefined; const providerId = typeof payload === 'string' ? payload : payload?.providerId; if (!providerId) throw new Error('Invalid provider.getApiKey payload'); data = await providerService.getLegacyProviderApiKey(providerId); break; } if (request.action === 'validateKey') { const payload = request.payload as | { providerId?: string; apiKey?: string; options?: { baseUrl?: string; apiProtocol?: string } } | [string, string, { baseUrl?: string; apiProtocol?: string }?] | undefined; const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId; const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey; const options = Array.isArray(payload) ? payload[2] : payload?.options; if (!providerId || typeof apiKey !== 'string') { throw new Error('Invalid provider.validateKey payload'); } const provider = await providerService.getLegacyProvider(providerId); const providerType = provider?.type || providerId; const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl; const resolvedProtocol = options?.apiProtocol || provider?.apiProtocol; data = await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol, }); break; } if (request.action === 'save') { const payload = request.payload as | { config?: ProviderConfig; apiKey?: string } | [ProviderConfig, string?] | undefined; const config = Array.isArray(payload) ? payload[0] : payload?.config; const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey; if (!config) throw new Error('Invalid provider.save payload'); try { await providerService.saveLegacyProvider(config); if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { await providerService.setLegacyProviderApiKey(config.id, trimmedKey); } } try { await syncSavedProviderToRuntime(config, apiKey, gatewayManager); } catch (err) { console.warn('Failed to sync openclaw provider config:', err); } data = { success: true }; } catch (error) { data = { success: false, error: String(error) }; } break; } if (request.action === 'delete') { const payload = request.payload as { providerId?: string } | string | undefined; const providerId = typeof payload === 'string' ? payload : payload?.providerId; if (!providerId) throw new Error('Invalid provider.delete payload'); try { const existing = await providerService.getLegacyProvider(providerId); await providerService.deleteLegacyProvider(providerId); if (existing?.type) { try { await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); } catch (err) { console.warn('Failed to completely remove provider from OpenClaw:', err); } } data = { success: true }; } catch (error) { data = { success: false, error: String(error) }; } break; } if (request.action === 'setApiKey') { const payload = request.payload as | { providerId?: string; apiKey?: string } | [string, string] | undefined; const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId; const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey; if (!providerId || typeof apiKey !== 'string') throw new Error('Invalid provider.setApiKey payload'); try { await providerService.setLegacyProviderApiKey(providerId, apiKey); const provider = await providerService.getLegacyProvider(providerId); const providerType = provider?.type || providerId; const ock = getOpenClawProviderKey(providerType, providerId); try { await saveProviderKeyToOpenClaw(ock, apiKey); } catch (err) { console.warn('Failed to save key to OpenClaw auth-profiles:', err); } data = { success: true }; } catch (error) { data = { success: false, error: String(error) }; } break; } if (request.action === 'updateWithKey') { const payload = request.payload as | { providerId?: string; updates?: Partial; apiKey?: string } | [string, Partial, string?] | undefined; const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId; const updates = Array.isArray(payload) ? payload[1] : payload?.updates; const apiKey = Array.isArray(payload) ? payload[2] : payload?.apiKey; if (!providerId || !updates) throw new Error('Invalid provider.updateWithKey payload'); const existing = await providerService.getLegacyProvider(providerId); if (!existing) { data = { success: false, error: 'Provider not found' }; break; } const previousKey = await providerService.getLegacyProviderApiKey(providerId); const previousOck = getOpenClawProviderKey(existing.type, providerId); try { const nextConfig: ProviderConfig = { ...existing, ...updates, updatedAt: new Date().toISOString(), }; const ock = getOpenClawProviderKey(nextConfig.type, providerId); await providerService.saveLegacyProvider(nextConfig); if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { await providerService.setLegacyProviderApiKey(providerId, trimmedKey); await saveProviderKeyToOpenClaw(ock, trimmedKey); } else { await providerService.deleteLegacyProviderApiKey(providerId); await removeProviderFromOpenClaw(ock); } } try { await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager); } catch (err) { console.warn('Failed to sync openclaw config after provider update:', err); } data = { success: true }; } catch (error) { try { await providerService.saveLegacyProvider(existing); if (previousKey) { await providerService.setLegacyProviderApiKey(providerId, previousKey); await saveProviderKeyToOpenClaw(previousOck, previousKey); } else { await providerService.deleteLegacyProviderApiKey(providerId); await removeProviderFromOpenClaw(previousOck); } } catch (rollbackError) { console.warn('Failed to rollback provider updateWithKey:', rollbackError); } data = { success: false, error: String(error) }; } break; } if (request.action === 'deleteApiKey') { const payload = request.payload as { providerId?: string } | string | undefined; const providerId = typeof payload === 'string' ? payload : payload?.providerId; if (!providerId) throw new Error('Invalid provider.deleteApiKey payload'); try { await providerService.deleteLegacyProviderApiKey(providerId); const provider = await providerService.getLegacyProvider(providerId); const providerType = provider?.type || providerId; const ock = getOpenClawProviderKey(providerType, providerId); try { if (ock) { await removeProviderFromOpenClaw(ock); } } catch (err) { console.warn('Failed to completely remove provider from OpenClaw:', err); } data = { success: true }; } catch (error) { data = { success: false, error: String(error) }; } break; } if (request.action === 'setDefault') { const payload = request.payload as { providerId?: string } | string | undefined; const providerId = typeof payload === 'string' ? payload : payload?.providerId; if (!providerId) throw new Error('Invalid provider.setDefault payload'); try { await providerService.setDefaultLegacyProvider(providerId); const provider = await providerService.getLegacyProvider(providerId); if (provider) { try { await syncDefaultProviderToRuntime(providerId, gatewayManager); } catch (err) { console.warn('Failed to set OpenClaw default model:', err); } } data = { success: true }; } catch (error) { data = { success: false, error: String(error) }; } break; } return { id: request.id, ok: false, error: { code: 'UNSUPPORTED', message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, }, }; } case 'update': { if (request.action === 'status') { data = appUpdater.getStatus(); break; } if (request.action === 'version') { data = appUpdater.getCurrentVersion(); break; } if (request.action === 'check') { try { await appUpdater.checkForUpdates(); data = { success: true, status: appUpdater.getStatus() }; } catch (error) { data = { success: false, error: String(error), status: appUpdater.getStatus() }; } break; } if (request.action === 'download') { try { await appUpdater.downloadUpdate(); data = { success: true }; } catch (error) { data = { success: false, error: String(error) }; } break; } if (request.action === 'install') { appUpdater.quitAndInstall(); data = { success: true }; break; } if (request.action === 'setChannel') { const payload = request.payload as { channel?: 'stable' | 'beta' | 'dev' } | 'stable' | 'beta' | 'dev' | undefined; const channel = typeof payload === 'string' ? payload : payload?.channel; if (!channel) throw new Error('Invalid update.setChannel payload'); appUpdater.setChannel(channel); data = { success: true }; break; } if (request.action === 'setAutoDownload') { const payload = request.payload as { enable?: boolean } | boolean | undefined; const enable = typeof payload === 'boolean' ? payload : payload?.enable; if (typeof enable !== 'boolean') throw new Error('Invalid update.setAutoDownload payload'); appUpdater.setAutoDownload(enable); data = { success: true }; break; } if (request.action === 'cancelAutoInstall') { appUpdater.cancelAutoInstall(); data = { success: true }; break; } return { id: request.id, ok: false, error: { code: 'UNSUPPORTED', message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, }, }; } case 'cron': { if (request.action === 'list') { const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }); const jobs = (result as { jobs?: GatewayCronJob[] })?.jobs ?? []; data = jobs.map(transformCronJob); break; } if (request.action === 'create') { type CronCreateInput = { name: string; message: string; schedule: string; delivery?: { mode: string; channel?: string; to?: string }; enabled?: boolean; }; const payload = request.payload as | { input?: CronCreateInput } | [CronCreateInput] | CronCreateInput | undefined; let input: CronCreateInput | undefined; if (Array.isArray(payload)) { input = payload[0]; } else if (payload && typeof payload === 'object' && 'input' in payload) { input = payload.input; } else { input = payload as CronCreateInput | undefined; } if (!input) throw new Error('Invalid cron.create payload'); const gatewayInput = { name: input.name, schedule: { kind: 'cron', expr: input.schedule }, payload: { kind: 'agentTurn', message: input.message }, enabled: input.enabled ?? true, wakeMode: 'next-heartbeat', sessionTarget: 'isolated', delivery: normalizeCronDelivery(input.delivery), }; const unsupportedDeliveryError = getUnsupportedCronDeliveryError(gatewayInput.delivery.channel); if (gatewayInput.delivery.mode === 'announce' && unsupportedDeliveryError) { throw new Error(unsupportedDeliveryError); } const created = await gatewayManager.rpc('cron.add', gatewayInput); data = created && typeof created === 'object' ? transformCronJob(created as GatewayCronJob) : created; break; } if (request.action === 'update') { const payload = request.payload as | { id?: string; input?: Record } | [string, Record] | undefined; const id = Array.isArray(payload) ? payload[0] : payload?.id; const input = Array.isArray(payload) ? payload[1] : payload?.input; if (!id || !input) throw new Error('Invalid cron.update payload'); const patch = buildCronUpdatePatch(input); const deliveryPatch = patch.delivery && typeof patch.delivery === 'object' ? patch.delivery as Record : undefined; const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim() ? deliveryPatch.channel.trim() : undefined; const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim() ? deliveryPatch.mode.trim() : undefined; const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel); if (unsupportedDeliveryError && deliveryMode !== 'none') { throw new Error(unsupportedDeliveryError); } data = await gatewayManager.rpc('cron.update', { id, patch }); break; } if (request.action === 'delete') { const payload = request.payload as { id?: string } | string | undefined; const id = typeof payload === 'string' ? payload : payload?.id; if (!id) throw new Error('Invalid cron.delete payload'); data = await gatewayManager.rpc('cron.remove', { id }); break; } if (request.action === 'toggle') { const payload = request.payload as { id?: string; enabled?: boolean } | [string, boolean] | undefined; const id = Array.isArray(payload) ? payload[0] : payload?.id; const enabled = Array.isArray(payload) ? payload[1] : payload?.enabled; if (!id || typeof enabled !== 'boolean') throw new Error('Invalid cron.toggle payload'); data = await gatewayManager.rpc('cron.update', { id, patch: { enabled } }); break; } if (request.action === 'trigger') { const payload = request.payload as { id?: string } | string | undefined; const id = typeof payload === 'string' ? payload : payload?.id; if (!id) throw new Error('Invalid cron.trigger payload'); data = await gatewayManager.rpc('cron.run', { id, mode: 'force' }); break; } return { id: request.id, ok: false, error: { code: 'UNSUPPORTED', message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, }, }; } case 'usage': { if (request.action === 'recentTokenHistory') { const payload = request.payload as { limit?: number } | number | undefined; const limit = typeof payload === 'number' ? payload : payload?.limit; const safeLimit = typeof limit === 'number' && Number.isFinite(limit) ? Math.max(Math.floor(limit), 1) : undefined; data = await getRecentTokenUsageHistory(safeLimit); break; } return { id: request.id, ok: false, error: { code: 'UNSUPPORTED', message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, }, }; } case 'settings': { if (request.action === 'getAll') { data = await getAllSettings(); break; } if (request.action === 'get') { const payload = request.payload as { key?: keyof AppSettings } | [keyof AppSettings] | undefined; const key = Array.isArray(payload) ? payload[0] : payload?.key; if (!key) throw new Error('Invalid settings.get payload'); data = await getSetting(key); break; } if (request.action === 'set') { const payload = request.payload as | { key?: keyof AppSettings; value?: AppSettings[keyof AppSettings] } | [keyof AppSettings, AppSettings[keyof AppSettings]] | undefined; const key = Array.isArray(payload) ? payload[0] : payload?.key; const value = Array.isArray(payload) ? payload[1] : payload?.value; if (!key) throw new Error('Invalid settings.set payload'); await setSetting(key, value as never); if (isProxyKey(key)) { await handleProxySettingsChange(); } if (isLaunchAtStartupKey(key)) { await syncLaunchAtStartupSettingFromStore(); } data = { success: true }; break; } if (request.action === 'setMany') { const patch = (request.payload ?? {}) as Partial; const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>; for (const [key, value] of entries) { await setSetting(key, value as never); } if (entries.some(([key]) => isProxyKey(key))) { await handleProxySettingsChange(); } if (entries.some(([key]) => isLaunchAtStartupKey(key))) { await syncLaunchAtStartupSettingFromStore(); } data = { success: true }; break; } if (request.action === 'reset') { await resetSettings(); const settings = await getAllSettings(); await handleProxySettingsChange(); await syncLaunchAtStartupSettingFromStore(); data = { success: true, settings }; break; } return { id: request.id, ok: false, error: { code: 'UNSUPPORTED', message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, }, }; } default: return { id: request.id, ok: false, error: { code: 'UNSUPPORTED', message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`, }, }; } return { id: request.id, ok: true, data }; } catch (error) { return { id: request.id, ok: false, error: { code: mapAppErrorCode(error), message: error instanceof Error ? error.message : String(error), }, }; } }); } /** * Skill config IPC handlers * Direct read/write to ~/.openclaw/openclaw.json (bypasses Gateway RPC) */ function registerSkillConfigHandlers(): void { // Update skill config (apiKey and env) ipcMain.handle('skill:updateConfig', async (_, params: { skillKey: string; apiKey?: string; env?: Record; }) => { return await updateSkillConfig(params.skillKey, { apiKey: params.apiKey, env: params.env, }); }); // Get skill config ipcMain.handle('skill:getConfig', async (_, skillKey: string) => { return await getSkillConfig(skillKey); }); // Get all skill configs ipcMain.handle('skill:getAllConfigs', async () => { return await getAllSkillConfigs(); }); } /** * Gateway CronJob type (as returned by cron.list RPC) */ interface GatewayCronJob { id: string; name: string; description?: string; enabled: boolean; createdAtMs: number; updatedAtMs: number; schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string }; payload: { kind: string; message?: string; text?: string }; delivery?: { mode: string; channel?: string; to?: string; accountId?: string }; sessionTarget?: string; state: { nextRunAtMs?: number; lastRunAtMs?: number; lastStatus?: string; lastError?: string; lastDurationMs?: number; }; } type GatewayCronDelivery = NonNullable; function getUnsupportedCronDeliveryError(_channel: string | undefined): string | null { // Channel support is gated by the frontend whitelist (TESTED_CRON_DELIVERY_CHANNELS). // No per-channel backend blocks are needed. return null; } function normalizeCronDelivery( rawDelivery: unknown, fallbackMode: GatewayCronDelivery['mode'] = 'none', ): GatewayCronDelivery { if (!rawDelivery || typeof rawDelivery !== 'object') { return { mode: fallbackMode }; } const delivery = rawDelivery as Record; const mode = typeof delivery.mode === 'string' && delivery.mode.trim() ? delivery.mode.trim() : fallbackMode; const channel = typeof delivery.channel === 'string' && delivery.channel.trim() ? toOpenClawChannelType(delivery.channel.trim()) : undefined; const to = typeof delivery.to === 'string' && delivery.to.trim() ? delivery.to.trim() : undefined; const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim() ? delivery.accountId.trim() : undefined; if (mode === 'announce' && !channel) { return { mode: 'none' }; } return { mode, ...(channel ? { channel } : {}), ...(to ? { to } : {}), ...(accountId ? { accountId } : {}), }; } function normalizeCronDeliveryPatch(rawDelivery: unknown): Record { if (!rawDelivery || typeof rawDelivery !== 'object') { return {}; } const delivery = rawDelivery as Record; const patch: Record = {}; if ('mode' in delivery) { patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim() ? delivery.mode.trim() : 'none'; } if ('channel' in delivery) { patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim() ? toOpenClawChannelType(delivery.channel.trim()) : ''; } if ('to' in delivery) { patch.to = typeof delivery.to === 'string' ? delivery.to : ''; } if ('accountId' in delivery) { patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : ''; } return patch; } function buildCronUpdatePatch(input: Record): Record { const patch = { ...input }; if (typeof patch.schedule === 'string') { patch.schedule = { kind: 'cron', expr: patch.schedule }; } if (typeof patch.message === 'string') { patch.payload = { kind: 'agentTurn', message: patch.message }; delete patch.message; } if ('delivery' in patch) { patch.delivery = normalizeCronDeliveryPatch(patch.delivery); } return patch; } /** * Transform a Gateway CronJob to the frontend CronJob format */ function transformCronJob(job: GatewayCronJob) { // Extract message from payload const message = job.payload?.message || job.payload?.text || ''; const gatewayDelivery = normalizeCronDelivery(job.delivery); const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined; const delivery = channelType ? { ...gatewayDelivery, channel: channelType } : gatewayDelivery; // Build target from delivery info — only if a delivery channel is specified const target = channelType ? { channelType, channelId: delivery.accountId || gatewayDelivery.channel, channelName: channelType, recipient: delivery.to } : undefined; // Build lastRun from state const lastRun = job.state?.lastRunAtMs ? { time: new Date(job.state.lastRunAtMs).toISOString(), success: job.state.lastStatus === 'ok', error: job.state.lastError, duration: job.state.lastDurationMs, } : undefined; // Build nextRun from state const nextRun = job.state?.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : undefined; return { id: job.id, name: job.name, message, schedule: job.schedule, // Pass the object through; frontend parseCronSchedule handles it delivery, target, enabled: job.enabled, createdAt: new Date(job.createdAtMs).toISOString(), updatedAt: new Date(job.updatedAtMs).toISOString(), lastRun, nextRun, }; } /** * Cron task IPC handlers * Proxies cron operations to the Gateway RPC service. * The frontend works with plain cron expression strings, but the Gateway * expects CronSchedule objects ({ kind: "cron", expr: "..." }). * These handlers bridge the two formats. */ function registerCronHandlers(gatewayManager: GatewayManager): void { // List all cron jobs — transforms Gateway CronJob format to frontend CronJob format ipcMain.handle('cron:list', async () => { try { const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }); const jobs = Array.isArray(result) ? result : (result as { jobs?: GatewayCronJob[] })?.jobs ?? []; // Auto-repair legacy UI-created jobs that were saved without // delivery: { mode: 'none' }. The Gateway auto-normalizes them // to delivery: { mode: 'announce' } which then fails with // "Channel is required" when no external channels are configured. for (const job of jobs) { const isIsolatedAgent = (job.sessionTarget === 'isolated' || !job.sessionTarget) && job.payload?.kind === 'agentTurn'; const needsRepair = isIsolatedAgent && job.delivery?.mode === 'announce' && !job.delivery?.channel; if (needsRepair) { try { await gatewayManager.rpc('cron.update', { id: job.id, patch: { delivery: { mode: 'none' } }, }); job.delivery = { mode: 'none' }; // Clear stale channel-resolution error from the last run if (job.state?.lastError?.includes('Channel is required')) { job.state.lastError = undefined; job.state.lastStatus = 'ok'; } } catch (e) { console.warn(`Failed to auto-repair cron job ${job.id}:`, e); } } } // Transform Gateway format to frontend format return jobs.map(transformCronJob); } catch (error) { console.error('Failed to list cron jobs:', error); throw error; } }); // Create a new cron job // UI-created tasks have no delivery target — results go to the ClawX chat page. // Tasks created via external channels (Feishu, Discord, etc.) are handled // directly by the OpenClaw Gateway and do not pass through this IPC handler. ipcMain.handle('cron:create', async (_, input: { name: string; message: string; schedule: string; delivery?: GatewayCronDelivery; enabled?: boolean; }) => { try { const gatewayInput = { name: input.name, schedule: { kind: 'cron', expr: input.schedule }, payload: { kind: 'agentTurn', message: input.message }, enabled: input.enabled ?? true, wakeMode: 'next-heartbeat', sessionTarget: 'isolated', // UI-created jobs deliver results via ClawX WebSocket chat events, // not external messaging channels. Setting mode='none' prevents // the Gateway from attempting channel delivery (which would fail // with "Channel is required" when no channels are configured). delivery: normalizeCronDelivery(input.delivery), }; const unsupportedDeliveryError = getUnsupportedCronDeliveryError(gatewayInput.delivery.channel); if (gatewayInput.delivery.mode === 'announce' && unsupportedDeliveryError) { throw new Error(unsupportedDeliveryError); } const result = await gatewayManager.rpc('cron.add', gatewayInput); // Transform the returned job to frontend format if (result && typeof result === 'object') { return transformCronJob(result as GatewayCronJob); } return result; } catch (error) { console.error('Failed to create cron job:', error); throw error; } }); // Update an existing cron job ipcMain.handle('cron:update', async (_, id: string, input: Record) => { try { const patch = buildCronUpdatePatch(input); const deliveryPatch = patch.delivery && typeof patch.delivery === 'object' ? patch.delivery as Record : undefined; const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim() ? deliveryPatch.channel.trim() : undefined; const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim() ? deliveryPatch.mode.trim() : undefined; const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel); if (unsupportedDeliveryError && deliveryMode !== 'none') { throw new Error(unsupportedDeliveryError); } const result = await gatewayManager.rpc('cron.update', { id, patch }); return result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result; } catch (error) { console.error('Failed to update cron job:', error); throw error; } }); // Delete a cron job ipcMain.handle('cron:delete', async (_, id: string) => { try { const result = await gatewayManager.rpc('cron.remove', { id }); return result; } catch (error) { console.error('Failed to delete cron job:', error); throw error; } }); // Toggle a cron job enabled/disabled ipcMain.handle('cron:toggle', async (_, id: string, enabled: boolean) => { try { const result = await gatewayManager.rpc('cron.update', { id, patch: { enabled } }); return result; } catch (error) { console.error('Failed to toggle cron job:', error); throw error; } }); // Trigger a cron job manually ipcMain.handle('cron:trigger', async (_, id: string) => { try { const result = await gatewayManager.rpc('cron.run', { id, mode: 'force' }); return result; } catch (error) { console.error('Failed to trigger cron job:', error); throw error; } }); // Periodic cron job repair: checks for jobs with undefined agentId and repairs them // This handles cases where cron jobs were created via openclaw CLI without specifying agent const CRON_AGENT_REPAIR_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes let _lastRepairErrorLogAt = 0; const REPAIR_ERROR_LOG_INTERVAL_MS = 60 * 60 * 1000; // 1 hour setInterval(async () => { try { const status = gatewayManager.getStatus(); if (status.state !== 'running') return; const result = await gatewayManager.rpc('cron.list', { includeDisabled: true }); const jobs = Array.isArray(result) ? result : (result as { jobs?: Array<{ id: string; name: string; sessionTarget?: string; payload?: { kind: string }; delivery?: { mode: string; channel?: string; to?: string; accountId?: string }; state?: Record }> })?.jobs ?? []; for (const job of jobs) { const jobAgentId = (job as unknown as { agentId?: string }).agentId; if ( (job.sessionTarget === 'isolated' || !job.sessionTarget) && job.payload?.kind === 'agentTurn' && job.delivery?.mode === 'announce' && job.delivery?.channel && jobAgentId === undefined ) { const channel = job.delivery.channel; const accountId = job.delivery.accountId; const toAddress = job.delivery.to; let correctAgentId = await resolveAgentIdFromChannel(channel, accountId); // If no accountId, try to resolve it from session history let resolvedAccountId: string | null = null; if (!correctAgentId && !accountId && toAddress) { resolvedAccountId = await resolveAccountIdFromSessionHistory(toAddress, channel); if (resolvedAccountId) { correctAgentId = await resolveAgentIdFromChannel(channel, resolvedAccountId); } } if (correctAgentId) { console.debug(`Periodic repair: job "${job.name}" agentId undefined -> "${correctAgentId}"`); // When accountId was resolved via to address, include it in the patch const patch: Record = { agentId: correctAgentId }; if (resolvedAccountId && !accountId) { patch.delivery = { accountId: resolvedAccountId }; } await gatewayManager.rpc('cron.update', { id: job.id, patch }); } } } } catch (error) { const now = Date.now(); if (now - _lastRepairErrorLogAt >= REPAIR_ERROR_LOG_INTERVAL_MS) { _lastRepairErrorLogAt = now; console.debug('Periodic cron repair error:', error); } } }, CRON_AGENT_REPAIR_INTERVAL_MS); } /** * UV-related IPC handlers */ function registerUvHandlers(): void { // Check if uv is installed ipcMain.handle('uv:check', async () => { return await checkUvInstalled(); }); // Install uv and setup managed Python ipcMain.handle('uv:install-all', async () => { try { const isInstalled = await checkUvInstalled(); if (!isInstalled) { await installUv(); } // Always run python setup to ensure it exists in uv's cache await setupManagedPython(); return { success: true }; } catch (error) { console.error('Failed to setup uv/python:', error); return { success: false, error: String(error) }; } }); } /** * Log-related IPC handlers * Allows the renderer to read application logs for diagnostics */ function registerLogHandlers(): void { // Get recent logs from memory ring buffer ipcMain.handle('log:getRecent', async (_, count?: number) => { return logger.getRecentLogs(count); }); // Read log file content (last N lines) ipcMain.handle('log:readFile', async (_, tailLines?: number) => { return await logger.readLogFile(tailLines); }); // Get log file path (so user can open in file explorer) ipcMain.handle('log:getFilePath', async () => { return logger.getLogFilePath(); }); // Get log directory path ipcMain.handle('log:getDir', async () => { return logger.getLogDir(); }); // List all log files ipcMain.handle('log:listFiles', async () => { return await logger.listLogFiles(); }); } /** * Gateway-related IPC handlers */ function registerGatewayHandlers( gatewayManager: GatewayManager, mainWindow: BrowserWindow ): void { type GatewayHttpProxyRequest = { path?: string; method?: string; headers?: Record; body?: unknown; timeoutMs?: number; }; // Get Gateway status ipcMain.handle('gateway:status', () => { return gatewayManager.getStatus(); }); // Check if Gateway is connected ipcMain.handle('gateway:isConnected', () => { return gatewayManager.isConnected(); }); // Start Gateway ipcMain.handle('gateway:start', async () => { try { await gatewayManager.start(); return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }); // Stop Gateway ipcMain.handle('gateway:stop', async () => { try { await gatewayManager.stop(); return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }); // Restart Gateway ipcMain.handle('gateway:restart', async () => { try { await gatewayManager.restart(); return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }); // Gateway RPC call ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => { try { const result = await gatewayRpcBackpressure.run( method, params, timeoutMs, (rpcMethod, rpcParams, rpcTimeoutMs) => gatewayManager.rpc(rpcMethod, rpcParams, rpcTimeoutMs), ); return { success: true, result }; } catch (error) { logger.warn(`[gateway:rpc] ${method} failed (timeoutMs=${timeoutMs ?? 30000}): ${String(error)}`); return { success: false, error: String(error) }; } }); // Gateway HTTP proxy // Renderer must not call gateway HTTP directly (CORS); all HTTP traffic // should go through this main-process proxy. ipcMain.handle('gateway:httpProxy', async (_, request: GatewayHttpProxyRequest) => { try { const status = gatewayManager.getStatus(); const port = status.port || 18789; const path = request?.path && request.path.startsWith('/') ? request.path : '/'; const method = (request?.method || 'GET').toUpperCase(); const timeoutMs = typeof request?.timeoutMs === 'number' && request.timeoutMs > 0 ? request.timeoutMs : 15000; const token = await getSetting('gatewayToken'); const headers: Record = { ...(request?.headers ?? {}), }; if (!headers.Authorization && !headers.authorization && token) { headers.Authorization = `Bearer ${token}`; } let body: string | undefined; if (request?.body !== undefined && request?.body !== null) { body = typeof request.body === 'string' ? request.body : JSON.stringify(request.body); if (!headers['Content-Type'] && !headers['content-type']) { headers['Content-Type'] = 'application/json'; } } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const response = await (async () => { try { return await proxyAwareFetch(`http://127.0.0.1:${port}${path}`, { method, headers, body, signal: controller.signal, }); } finally { clearTimeout(timer); } })(); const contentType = (response.headers.get('content-type') || '').toLowerCase(); if (contentType.includes('application/json')) { const json = await response.json(); return { success: true, status: response.status, ok: response.ok, json, }; } const text = await response.text(); return { success: true, status: response.status, ok: response.ok, text, }; } catch (error) { return { success: false, error: String(error), }; } }); // Chat send with media — reads staged files from disk and builds attachments. // Raster images (png/jpg/gif/webp) are inlined as base64 vision attachments. // All other files are referenced by path in the message text so the model // can access them via tools (the same format channels use). const VISION_MIME_TYPES = new Set([ 'image/png', 'image/jpeg', 'image/bmp', 'image/webp', ]); ipcMain.handle('chat:sendWithMedia', async (_, params: { sessionKey: string; message: string; deliver?: boolean; idempotencyKey: string; media?: Array<{ filePath: string; mimeType: string; fileName: string }>; }) => { try { let message = params.message; // The Gateway processes image attachments through TWO parallel paths: // Path A: `attachments` param → parsed via `parseMessageWithAttachments` → // injected as inline vision content when the model supports images. // Format: { content: base64, mimeType: string, fileName?: string } // Path B: `[media attached: ...]` in message text → Gateway's native image // detection (`detectAndLoadPromptImages`) reads the file from disk and // injects it as inline vision content. Also works for history messages. // We use BOTH paths for maximum reliability. const imageAttachments: Array> = []; const fileReferences: string[] = []; if (params.media && params.media.length > 0) { const fsP = await import('fs/promises'); for (const m of params.media) { 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( `[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`, ); if (VISION_MIME_TYPES.has(m.mimeType)) { // 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 = await fsP.readFile(m.filePath); const base64Data = fileBuffer.toString('base64'); logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`); imageAttachments.push({ content: base64Data, mimeType: m.mimeType, fileName: m.fileName, }); } } } // Append file references to message text so the model knows about them if (fileReferences.length > 0) { const refs = fileReferences.join('\n'); message = message ? `${message}\n\n${refs}` : refs; } const rpcParams: Record = { sessionKey: params.sessionKey, message, deliver: params.deliver ?? false, idempotencyKey: params.idempotencyKey, }; if (imageAttachments.length > 0) { rpcParams.attachments = imageAttachments; } logger.info(`[chat:sendWithMedia] Sending: message="${message.substring(0, 100)}", attachments=${imageAttachments.length}, fileRefs=${fileReferences.length}`); // Longer timeout for chat sends to tolerate high-latency networks (avoids connect error) const timeoutMs = 120000; const result = await gatewayManager.rpc('chat.send', rpcParams, timeoutMs); logger.info(`[chat:sendWithMedia] RPC result: ${JSON.stringify(result)}`); return { success: true, result }; } catch (error) { logger.error(`[chat:sendWithMedia] Error: ${String(error)}`); return { success: false, error: String(error) }; } }); // Get the Control UI URL with token for embedding ipcMain.handle('gateway:getControlUiUrl', async () => { try { const status = gatewayManager.getStatus(); const token = await getSetting('gatewayToken'); const port = status.port || 18789; const url = buildOpenClawControlUiUrl(port, token); return { success: true, url, port, token }; } catch (error) { return { success: false, error: String(error) }; } }); // Health check ipcMain.handle('gateway:health', async () => { try { const health = await gatewayManager.checkHealth(); return { success: true, ...health }; } catch (error) { return { success: false, ok: false, error: String(error) }; } }); // Forward Gateway events to renderer gatewayManager.on('status', (status) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:status-changed', status); } }); gatewayManager.on('message', (message) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:message', message); } }); gatewayManager.on('notification', (notification) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:notification', notification); } }); gatewayManager.on('gateway:health', (data) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:health-changed', data); } }); gatewayManager.on('gateway:presence', (data) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:presence-changed', data); } }); gatewayManager.on('channel:status', (data) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:channel-status', data); } }); gatewayManager.on('chat:message', (data) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:chat-message', data); } }); gatewayManager.on('exit', (code) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:exit', code); } }); gatewayManager.on('error', (error) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:error', error.message); } }); } /** * OpenClaw-related IPC handlers * For checking package status and channel configuration */ function registerOpenClawHandlers(gatewayManager: GatewayManager): void { // Plugin-based channels require a full Gateway process restart to properly // initialize / tear-down plugin connections. SIGUSR1 in-process reload is // not sufficient for channel plugins (see restartGatewayForAgentDeletion). const forceRestartChannels = new Set(['dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot']); const scheduleGatewayChannelRestart = (reason: string): void => { if (gatewayManager.getStatus().state !== 'stopped') { logger.info(`Scheduling Gateway restart after ${reason}`); gatewayManager.debouncedRestart(150); } else { logger.info(`Gateway is stopped; skip immediate restart after ${reason}`); } }; const scheduleGatewayChannelSaveRefresh = (channelType: string, reason: string): void => { if (gatewayManager.getStatus().state === 'stopped') { logger.info(`Gateway is stopped; skip immediate refresh after ${reason}`); return; } if (forceRestartChannels.has(channelType)) { logger.info(`Scheduling Gateway restart after ${reason}`); gatewayManager.debouncedRestart(150); return; } logger.info(`Scheduling Gateway reload after ${reason}`); gatewayManager.debouncedReload(150); }; // Get OpenClaw package status ipcMain.handle('openclaw:status', () => { const status = getOpenClawStatus(); logger.info('openclaw:status IPC called', status); return status; }); // Check if OpenClaw is ready (package present) ipcMain.handle('openclaw:isReady', () => { const status = getOpenClawStatus(); return status.packageExists; }); // Get the resolved OpenClaw directory path (for diagnostics) ipcMain.handle('openclaw:getDir', () => { return getOpenClawDir(); }); // Get the OpenClaw config directory (~/.openclaw) ipcMain.handle('openclaw:getConfigDir', () => { return getOpenClawConfigDir(); }); // Get the OpenClaw skills directory (~/.openclaw/skills) ipcMain.handle('openclaw:getSkillsDir', () => { const dir = getOpenClawSkillsDir(); ensureDir(dir); return dir; }); // Get a shell command to run OpenClaw CLI without modifying PATH ipcMain.handle('openclaw:getCliCommand', () => { try { const status = getOpenClawStatus(); if (!status.packageExists) { return { success: false, error: `OpenClaw package not found at: ${status.dir}` }; } if (!existsSync(status.entryPath)) { return { success: false, error: `OpenClaw entry script not found at: ${status.entryPath}` }; } return { success: true, command: getOpenClawCliCommand() }; } catch (error) { return { success: false, error: String(error) }; } }); // ==================== Channel Configuration Handlers ==================== // Save channel configuration ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record) => { try { logger.info('channel:saveConfig', { channelType, keys: Object.keys(config || {}) }); if (channelType === 'dingtalk') { const installResult = await ensureDingTalkPluginInstalled(); if (!installResult.installed) { return { success: false, error: installResult.warning || 'DingTalk plugin install failed', }; } await saveChannelConfig(channelType, config); scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`); return { success: true, pluginInstalled: installResult.installed, warning: installResult.warning, }; } if (channelType === 'wecom') { const installResult = await ensureWeComPluginInstalled(); if (!installResult.installed) { return { success: false, error: installResult.warning || 'WeCom plugin install failed', }; } await saveChannelConfig(channelType, config); scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`); return { success: true, pluginInstalled: installResult.installed, warning: installResult.warning, }; } // QQBot is a built-in channel since OpenClaw 3.31 — no plugin install needed if (channelType === 'feishu') { const installResult = await ensureFeishuPluginInstalled(); if (!installResult.installed) { return { success: false, error: installResult.warning || 'Feishu plugin install failed', }; } await saveChannelConfig(channelType, config); scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`); return { success: true, pluginInstalled: installResult.installed, warning: installResult.warning, }; } await saveChannelConfig(channelType, config); scheduleGatewayChannelSaveRefresh(channelType, `channel:saveConfig (${channelType})`); return { success: true }; } catch (error) { console.error('Failed to save channel config:', error); return { success: false, error: String(error) }; } }); // Get channel configuration ipcMain.handle('channel:getConfig', async (_, channelType: string) => { try { const config = await getChannelConfig(channelType); return { success: true, config }; } catch (error) { console.error('Failed to get channel config:', error); return { success: false, error: String(error) }; } }); // Get channel form values (reverse-transformed for UI pre-fill) ipcMain.handle('channel:getFormValues', async (_, channelType: string) => { try { const values = await getChannelFormValues(channelType); return { success: true, values }; } catch (error) { console.error('Failed to get channel form values:', error); return { success: false, error: String(error) }; } }); // Delete channel configuration ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => { try { await deleteChannelConfig(channelType); scheduleGatewayChannelRestart(`channel:deleteConfig (${channelType})`); return { success: true }; } catch (error) { console.error('Failed to delete channel config:', error); return { success: false, error: String(error) }; } }); // List configured channels ipcMain.handle('channel:listConfigured', async () => { try { const channels = await listConfiguredChannels(); return { success: true, channels }; } catch (error) { console.error('Failed to list channels:', error); return { success: false, error: String(error) }; } }); // Enable or disable a channel ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => { try { await setChannelEnabled(channelType, enabled); scheduleGatewayChannelRestart(`channel:setEnabled (${channelType}, enabled=${enabled})`); return { success: true }; } catch (error) { console.error('Failed to set channel enabled:', error); return { success: false, error: String(error) }; } }); // Validate channel configuration ipcMain.handle('channel:validate', async (_, channelType: string) => { try { const result = await validateChannelConfig(channelType); return { success: true, ...result }; } catch (error) { console.error('Failed to validate channel:', error); return { success: false, valid: false, errors: [String(error)], warnings: [] }; } }); // Validate channel credentials by calling actual service APIs (before saving) ipcMain.handle('channel:validateCredentials', async (_, channelType: string, config: Record) => { try { const result = await validateChannelCredentials(channelType, config); return { success: true, ...result }; } catch (error) { console.error('Failed to validate channel credentials:', error); return { success: false, valid: false, errors: [String(error)], warnings: [] }; } }); } /** * WhatsApp Login Handlers */ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void { // Request WhatsApp QR code ipcMain.handle('channel:requestWhatsAppQr', async (_, accountId: string) => { try { logger.info('channel:requestWhatsAppQr', { accountId }); await whatsAppLoginManager.start(accountId); return { success: true }; } catch (error) { logger.error('channel:requestWhatsAppQr failed', error); return { success: false, error: String(error) }; } }); // Cancel WhatsApp login ipcMain.handle('channel:cancelWhatsAppQr', async () => { try { await whatsAppLoginManager.stop(); return { success: true }; } catch (error) { logger.error('channel:cancelWhatsAppQr failed', error); return { success: false, error: String(error) }; } }); // Check WhatsApp status (is it active?) // ipcMain.handle('channel:checkWhatsAppStatus', ...) // Forward events to renderer whatsAppLoginManager.on('qr', (data) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('channel:whatsapp-qr', data); } }); whatsAppLoginManager.on('success', (data) => { if (!mainWindow.isDestroyed()) { logger.info('whatsapp:login-success', data); mainWindow.webContents.send('channel:whatsapp-success', data); } }); whatsAppLoginManager.on('error', (error) => { if (!mainWindow.isDestroyed()) { logger.error('whatsapp:login-error', error); mainWindow.webContents.send('channel:whatsapp-error', error); } }); } /** * Device OAuth Handlers (Code Plan) */ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { deviceOAuthManager.setWindow(mainWindow); browserOAuthManager.setWindow(mainWindow); // Request Provider OAuth initialization ipcMain.handle( 'provider:requestOAuth', async ( _, provider: OAuthProviderType | BrowserOAuthProviderType, region?: 'global' | 'cn', options?: { accountId?: string; label?: string }, ) => { try { logger.info(`provider:requestOAuth for ${provider}`); if (provider === 'openai') { await browserOAuthManager.startFlow(provider, options); } else { await deviceOAuthManager.startFlow(provider, region, options); } return { success: true }; } catch (error) { logger.error('provider:requestOAuth failed', error); return { success: false, error: String(error) }; } }, ); // Cancel Provider OAuth ipcMain.handle('provider:cancelOAuth', async () => { try { await deviceOAuthManager.stopFlow(); await browserOAuthManager.stopFlow(); return { success: true }; } catch (error) { logger.error('provider:cancelOAuth failed', error); return { success: false, error: String(error) }; } }); } /** * Provider-related IPC handlers */ function registerProviderHandlers(gatewayManager: GatewayManager): void { const providerService = getProviderService(); const legacyProviderChannelsWarned = new Set(); const logLegacyProviderChannel = (channel: string): void => { if (legacyProviderChannelsWarned.has(channel)) return; legacyProviderChannelsWarned.add(channel); logger.warn( `[provider-migration] Legacy IPC channel "${channel}" is deprecated. Prefer app:request provider actions and account APIs.`, ); }; // Listen for OAuth success to automatically restart the Gateway with new tokens/configs. // Keep a longer debounce (8s) so provider config writes and OAuth token persistence // can settle before applying the process-level refresh. deviceOAuthManager.on('oauth:success', ({ provider, accountId }) => { logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`); gatewayManager.debouncedRestart(8000); }); browserOAuthManager.on('oauth:success', ({ provider, accountId }) => { logger.info(`[IPC] Scheduling Gateway restart after ${provider} OAuth success for ${accountId}...`); gatewayManager.debouncedRestart(8000); }); // Get all providers with key info ipcMain.handle('provider:list', async () => { logLegacyProviderChannel('provider:list'); return await providerService.listLegacyProvidersWithKeyInfo(); }); // New provider-service endpoints used by the account-based refactor. ipcMain.handle('provider:listVendors', async () => { return await providerService.listVendors(); }); ipcMain.handle('provider:listAccounts', async () => { return await providerService.listAccounts(); }); ipcMain.handle('provider:getAccount', async (_, accountId: string) => { return await providerService.getAccount(accountId); }); // Get a specific provider ipcMain.handle('provider:get', async (_, providerId: string) => { logLegacyProviderChannel('provider:get'); return await providerService.getLegacyProvider(providerId); }); // Save a provider configuration ipcMain.handle('provider:save', async (_, config: ProviderConfig, apiKey?: string) => { logLegacyProviderChannel('provider:save'); try { // Save the provider config await providerService.saveLegacyProvider(config); // Store the API key if provided if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { await providerService.setLegacyProviderApiKey(config.id, trimmedKey); // Also write to OpenClaw auth-profiles.json so the gateway can use it try { await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey); } catch (err) { console.warn('Failed to save key to OpenClaw auth-profiles:', err); } } } // Sync the provider configuration to openclaw.json so Gateway knows about it try { await syncSavedProviderToRuntime(config, apiKey, gatewayManager); } catch (err) { console.warn('Failed to sync openclaw provider config:', err); } return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }); // Delete a provider ipcMain.handle('provider:delete', async (_, providerId: string) => { logLegacyProviderChannel('provider:delete'); try { const existing = await providerService.getLegacyProvider(providerId); await providerService.deleteLegacyProvider(providerId); // Best-effort cleanup in OpenClaw auth profiles & openclaw.json config if (existing?.type) { try { await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); } catch (err) { console.warn('Failed to completely remove provider from OpenClaw:', err); } } return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }); // Update API key for a provider ipcMain.handle('provider:setApiKey', async (_, providerId: string, apiKey: string) => { logLegacyProviderChannel('provider:setApiKey'); try { await providerService.setLegacyProviderApiKey(providerId, apiKey); // Also write to OpenClaw auth-profiles.json const provider = await providerService.getLegacyProvider(providerId); const providerType = provider?.type || providerId; try { await syncProviderApiKeyToRuntime(providerType, providerId, apiKey); } catch (err) { console.warn('Failed to save key to OpenClaw auth-profiles:', err); } return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }); // Atomically update provider config and API key ipcMain.handle( 'provider:updateWithKey', async ( _, providerId: string, updates: Partial, apiKey?: string ) => { logLegacyProviderChannel('provider:updateWithKey'); const existing = await providerService.getLegacyProvider(providerId); if (!existing) { return { success: false, error: 'Provider not found' }; } const previousKey = await providerService.getLegacyProviderApiKey(providerId); const previousOck = getOpenClawProviderKey(existing.type, providerId); try { const nextConfig: ProviderConfig = { ...existing, ...updates, updatedAt: new Date().toISOString(), }; const ock = getOpenClawProviderKey(nextConfig.type, providerId); await providerService.saveLegacyProvider(nextConfig); if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { await providerService.setLegacyProviderApiKey(providerId, trimmedKey); await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey); } else { await providerService.deleteLegacyProviderApiKey(providerId); await removeProviderFromOpenClaw(ock); } } // Sync the provider configuration to openclaw.json so Gateway knows about it try { await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager); } catch (err) { console.warn('Failed to sync openclaw config after provider update:', err); } return { success: true }; } catch (error) { // Best-effort rollback to keep config/key consistent. try { await providerService.saveLegacyProvider(existing); if (previousKey) { await providerService.setLegacyProviderApiKey(providerId, previousKey); await saveProviderKeyToOpenClaw(previousOck, previousKey); } else { await providerService.deleteLegacyProviderApiKey(providerId); await removeProviderFromOpenClaw(previousOck); } } catch (rollbackError) { console.warn('Failed to rollback provider updateWithKey:', rollbackError); } return { success: false, error: String(error) }; } } ); // Delete API key for a provider ipcMain.handle('provider:deleteApiKey', async (_, providerId: string) => { logLegacyProviderChannel('provider:deleteApiKey'); try { await providerService.deleteLegacyProviderApiKey(providerId); // Keep OpenClaw auth-profiles.json in sync with local key storage const provider = await providerService.getLegacyProvider(providerId); try { await syncDeletedProviderApiKeyToRuntime(provider, providerId); } catch (err) { console.warn('Failed to completely remove provider from OpenClaw:', err); } return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }); // Check if a provider has an API key ipcMain.handle('provider:hasApiKey', async (_, providerId: string) => { logLegacyProviderChannel('provider:hasApiKey'); return await providerService.hasLegacyProviderApiKey(providerId); }); // Get the actual API key (for internal use only - be careful!) ipcMain.handle('provider:getApiKey', async (_, providerId: string) => { logLegacyProviderChannel('provider:getApiKey'); return await providerService.getLegacyProviderApiKey(providerId); }); // Set default provider and update OpenClaw default model ipcMain.handle('provider:setDefault', async (_, providerId: string) => { logLegacyProviderChannel('provider:setDefault'); try { await providerService.setDefaultLegacyProvider(providerId); // Update OpenClaw config to use this provider's default model try { await syncDefaultProviderToRuntime(providerId, gatewayManager); } catch (err) { console.warn('Failed to set OpenClaw default model:', err); } return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }); // Get default provider ipcMain.handle('provider:getDefault', async () => { logLegacyProviderChannel('provider:getDefault'); return await providerService.getDefaultLegacyProvider(); }); // Validate API key by making a real test request to the provider. // providerId can be either a stored provider ID or a provider type. ipcMain.handle( 'provider:validateKey', async ( _, providerId: string, apiKey: string, options?: { baseUrl?: string; apiProtocol?: string } ) => { logLegacyProviderChannel('provider:validateKey'); try { // First try to get existing provider const provider = await providerService.getLegacyProvider(providerId); // Use provider.type if provider exists, otherwise use providerId as the type // This allows validation during setup when provider hasn't been saved yet const providerType = provider?.type || providerId; const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; // Prefer caller-supplied baseUrl (live form value) over persisted config. // This ensures Setup/Settings validation reflects unsaved edits immediately. const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl; const resolvedProtocol = options?.apiProtocol || provider?.apiProtocol; console.log(`[clawx-validate] validating provider type: ${providerType}`); return await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol, }); } catch (error) { console.error('Validation error:', error); return { valid: false, error: String(error) }; } } ); } /** * Shell-related IPC handlers */ function expandShellPath(input: string): string { if (input === '~') return homedir(); if (input.startsWith(`~${sep}`) || input.startsWith('~/') || input.startsWith('~\\')) { return join(homedir(), input.slice(2)); } return input; } function registerShellHandlers(): void { // Open external URL ipcMain.handle('shell:openExternal', async (_, url: string) => { await shell.openExternal(url); }); // Open path in file explorer ipcMain.handle('shell:showItemInFolder', async (_, path: string) => { shell.showItemInFolder(expandShellPath(path)); }); // Open path ipcMain.handle('shell:openPath', async (_, path: string) => { return await shell.openPath(expandShellPath(path)); }); } /** * ClawHub-related IPC handlers */ function registerClawHubHandlers(clawHubService: ClawHubService): void { // Search skills ipcMain.handle('clawhub:search', async (_, params: ClawHubSearchParams) => { try { const results = await clawHubService.search(params); return { success: true, results }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); // Install skill ipcMain.handle('clawhub:install', async (_, params: ClawHubInstallParams) => { try { await clawHubService.install(params); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); // Uninstall skill ipcMain.handle('clawhub:uninstall', async (_, params: ClawHubUninstallParams) => { try { await clawHubService.uninstall(params); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); // List installed skills ipcMain.handle('clawhub:list', async () => { try { const results = await clawHubService.listInstalled(); return { success: true, results }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); // Open skill readme ipcMain.handle('clawhub:openSkillReadme', async (_, slug: string) => { try { await clawHubService.openSkillReadme(slug); return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }); } /** * Dialog-related IPC handlers */ function registerDialogHandlers(): void { // Show open dialog ipcMain.handle('dialog:open', async (_, options: Electron.OpenDialogOptions) => { const result = await dialog.showOpenDialog(options); return result; }); // Show save dialog ipcMain.handle('dialog:save', async (_, options: Electron.SaveDialogOptions) => { const result = await dialog.showSaveDialog(options); return result; }); // Show message box ipcMain.handle('dialog:message', async (_, options: Electron.MessageBoxOptions) => { const result = await dialog.showMessageBox(options); return result; }); } /** * App-related IPC handlers */ function registerAppHandlers(): void { // Get app version ipcMain.handle('app:version', () => { return app.getVersion(); }); // Get app name ipcMain.handle('app:name', () => { return app.getName(); }); // Get app path ipcMain.handle('app:getPath', (_, name: Parameters[0]) => { return app.getPath(name); }); // Get platform ipcMain.handle('app:platform', () => { return process.platform; }); // Quit app ipcMain.handle('app:quit', () => { app.quit(); }); // Relaunch app ipcMain.handle('app:relaunch', () => { app.relaunch(); app.quit(); }); } function registerSettingsHandlers(gatewayManager: GatewayManager): void { const handleProxySettingsChange = async () => { const settings = await getAllSettings(); await syncProxyConfigToOpenClaw(settings, { preserveExistingWhenDisabled: false }); await applyProxySettings(settings); if (gatewayManager.getStatus().state === 'running') { await gatewayManager.restart(); } }; ipcMain.handle('settings:get', async (_, key: keyof AppSettings) => { return await getSetting(key); }); ipcMain.handle('settings:getAll', async () => { return await getAllSettings(); }); ipcMain.handle('settings:set', async (_, key: keyof AppSettings, value: AppSettings[keyof AppSettings]) => { await setSetting(key, value as never); if ( key === 'proxyEnabled' || key === 'proxyServer' || key === 'proxyHttpServer' || key === 'proxyHttpsServer' || key === 'proxyAllServer' || key === 'proxyBypassRules' ) { await handleProxySettingsChange(); } if (key === 'launchAtStartup') { await syncLaunchAtStartupSettingFromStore(); } return { success: true }; }); ipcMain.handle('settings:setMany', async (_, patch: Partial) => { const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>; for (const [key, value] of entries) { await setSetting(key, value as never); } if (entries.some(([key]) => key === 'proxyEnabled' || key === 'proxyServer' || key === 'proxyHttpServer' || key === 'proxyHttpsServer' || key === 'proxyAllServer' || key === 'proxyBypassRules' )) { await handleProxySettingsChange(); } if (entries.some(([key]) => key === 'launchAtStartup')) { await syncLaunchAtStartupSettingFromStore(); } return { success: true }; }); ipcMain.handle('settings:reset', async () => { await resetSettings(); const settings = await getAllSettings(); await handleProxySettingsChange(); await syncLaunchAtStartupSettingFromStore(); return { success: true, settings }; }); } function registerUsageHandlers(): void { ipcMain.handle('usage:recentTokenHistory', async (_, limit?: number) => { const safeLimit = typeof limit === 'number' && Number.isFinite(limit) ? Math.max(Math.floor(limit), 1) : undefined; return await getRecentTokenUsageHistory(safeLimit); }); } /** * Window control handlers (for custom title bar on Windows) */ function registerWindowHandlers(mainWindow: BrowserWindow): void { ipcMain.handle('window:minimize', () => { mainWindow.minimize(); }); ipcMain.handle('window:maximize', () => { if (mainWindow.isMaximized()) { mainWindow.unmaximize(); } else { mainWindow.maximize(); } }); ipcMain.handle('window:close', () => { mainWindow.close(); }); ipcMain.handle('window:isMaximized', () => { return mainWindow.isMaximized(); }); } // ── Mime type helpers ──────────────────────────────────────────── const EXT_MIME_MAP: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.avi': 'video/x-msvideo', '.mkv': 'video/x-matroska', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac', '.pdf': 'application/pdf', '.zip': 'application/zip', '.gz': 'application/gzip', '.tar': 'application/x-tar', '.7z': 'application/x-7z-compressed', '.rar': 'application/vnd.rar', '.json': 'application/json', '.xml': 'application/xml', '.csv': 'text/csv', '.txt': 'text/plain', '.md': 'text/markdown', '.html': 'text/html', '.htm': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.ts': 'text/typescript', '.py': 'text/x-python', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', }; function getMimeType(ext: string): string { return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream'; } function mimeToExt(mimeType: string): string { for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) { if (mime === mimeType) return ext; } return ''; } const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound'); /** * Generate a preview data URL for image files. * Resizes large images while preserving aspect ratio (only constrain the * longer side so the image is never squished). The frontend handles * square cropping via CSS object-fit: cover. */ async function generateImagePreview(filePath: string, mimeType: string): Promise { try { const img = nativeImage.createFromPath(filePath); if (img.isEmpty()) return null; const size = img.getSize(); const maxDim = 512; // keep enough resolution for crisp display on Retina // Only resize if larger than threshold — specify ONE dimension to keep ratio if (size.width > maxDim || size.height > maxDim) { const resized = size.width >= size.height ? img.resize({ width: maxDim }) // landscape / square → constrain width : img.resize({ height: maxDim }); // portrait → constrain height return `data:image/png;base64,${resized.toPNG().toString('base64')}`; } // 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; } } /** * File staging IPC handlers * Stage files to ~/.openclaw/media/outbound/ for gateway access */ function registerFileHandlers(): void { // Stage files from real disk paths (used with dialog:open) ipcMain.handle('file:stage', async (_, filePaths: string[]) => { 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}`); await fsP.copyFile(filePath, 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 = await generateImagePreview(stagedPath, mimeType); } results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview }); } return results; }); // Stage file from buffer (used for clipboard paste / drag-drop) ipcMain.handle('file:stageBuffer', async (_, payload: { base64: string; fileName: string; mimeType: string; }) => { 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'); await fsP.writeFile(stagedPath, buffer); const mimeType = payload.mimeType || getMimeType(ext); const fileSize = buffer.length; // Generate preview for images let preview: string | null = null; if (mimeType.startsWith('image/')) { preview = await generateImagePreview(stagedPath, mimeType); } return { id, fileName: payload.fileName, mimeType, fileSize, stagedPath, preview }; }); // Load thumbnails for file paths on disk (used to restore previews in history) // Save an image to a user-chosen location (base64 data URI or existing file path) ipcMain.handle('media:saveImage', async (_, params: { base64?: string; mimeType?: string; filePath?: string; defaultFileName: string; }) => { try { const ext = params.defaultFileName.includes('.') ? params.defaultFileName.split('.').pop()! : (params.mimeType?.split('/')[1] || 'png'); const result = await dialog.showSaveDialog({ defaultPath: join(homedir(), 'Downloads', params.defaultFileName), filters: [ { name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] }, { name: 'All Files', extensions: ['*'] }, ], }); if (result.canceled || !result.filePath) return { success: false }; 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'); await fsP.writeFile(result.filePath, buffer); } else { return { success: false, error: 'No image data provided' }; } return { success: true, savedPath: result.filePath }; } catch (err) { return { success: false, error: String(err) }; } }); ipcMain.handle('media:getThumbnails', async ( _, paths: Array<{ filePath?: string; gatewayUrl?: string; mimeType: string }>, ) => { const fsP = await import('fs/promises'); const results: Record = {}; for (const entry of paths) { // Local on-disk file (the original code path). if (entry.filePath) { try { const s = await fsP.stat(entry.filePath); let preview: string | null = null; if (entry.mimeType.startsWith('image/')) { preview = await generateImagePreview(entry.filePath, entry.mimeType); } results[entry.filePath] = { preview, fileSize: s.size }; } catch { results[entry.filePath] = { preview: null, fileSize: 0 }; } continue; } // Gateway-injected outgoing media URL. The renderer cannot reach the // Gateway HTTP server directly (CORS / env drift), so we resolve it // here against OpenClaw's local outgoing media records and load the // original file off disk. The URL shape is fixed by OpenClaw: // /api/chat/media/outgoing///full if (entry.gatewayUrl) { const resolved = await resolveOutgoingMediaUrl(entry.gatewayUrl); if (!resolved) { results[entry.gatewayUrl] = { preview: null, fileSize: 0 }; continue; } try { const s = await fsP.stat(resolved.path); let preview: string | null = null; if (resolved.mimeType.startsWith('image/')) { preview = await generateImagePreview(resolved.path, resolved.mimeType); } results[entry.gatewayUrl] = { preview, fileSize: s.size }; } catch { results[entry.gatewayUrl] = { preview: null, fileSize: 0 }; } } } return results; }); } /** * Resolve a Gateway-emitted outgoing-media URL to the original file on disk. * * OpenClaw's runtime stages every assistant `MEDIA:/path` artifact under * `~/.openclaw/media/outgoing/`: * - `originals/.` — the source bytes copied verbatim * - `records/.json` — `{ original: { path, contentType, ... }, ... }` * * The Gateway then injects an `assistant-media` content block with * `url:'/api/chat/media/outgoing///full'`. * We only need the `` segment to look up the record. */ async function resolveOutgoingMediaUrl( gatewayUrl: string, ): Promise<{ path: string; mimeType: string } | null> { try { const m = gatewayUrl.match(/\/api\/chat\/media\/outgoing\/[^/]+\/([^/]+)\//); if (!m) return null; const attachmentId = decodeURIComponent(m[1]); if (!/^[A-Za-z0-9._-]+$/.test(attachmentId)) return null; const recordPath = join(homedir(), '.openclaw', 'media', 'outgoing', 'records', `${attachmentId}.json`); const fsP = await import('fs/promises'); const raw = await fsP.readFile(recordPath, 'utf8'); const record = JSON.parse(raw) as { original?: { path?: string; contentType?: string }; }; const original = record?.original; if (!original?.path) return null; return { path: original.path, mimeType: typeof original.contentType === 'string' && original.contentType ? original.contentType : 'application/octet-stream', }; } catch { return null; } } /** * Session IPC handlers * * Performs a HARD delete of a session's JSONL transcript on disk. * sessionKey format: "agent::" — e.g. "agent:main:session-1234567890". * The JSONL file lives at: ~/.openclaw/agents//sessions/.jsonl * (where is typically a UUID resolved via sessions.json). * * For each deleted session we unlink every file that belongs to its on-disk id: * - .jsonl — the live transcript * - .deleted.jsonl — leftovers from earlier soft-delete releases * - .jsonl.reset.* — historical snapshots produced by sessions.reset * - .trajectory.jsonl — OpenClaw runtime "flight recorder" sidecar * - .trajectory-path.json — pointer to the runtime trajectory; if it * points outside the sessions/ folder * (OPENCLAW_TRAJECTORY_DIR override) the * referenced file is unlinked too. * * The session entry is also removed from sessions.json so sessions.list stops * surfacing it. Token-usage history reported by the Dashboard reads the same * transcripts, so deleted conversations stop contributing to the chart. * * Path resolution and the sibling sweep are shared with the HTTP mirror at * `electron/api/routes/sessions.ts` via `electron/utils/session-files.ts`, * so both surfaces unlink the same set of files for a given session id. */ const SAFE_AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; function registerSessionHandlers(): void { ipcMain.handle('session:delete', async (_, sessionKey: string) => { try { if (!sessionKey || !sessionKey.startsWith('agent:')) { return { success: false, error: `Invalid sessionKey: ${sessionKey}` }; } const parts = sessionKey.split(':'); if (parts.length < 3) { return { success: false, error: `sessionKey has too few parts: ${sessionKey}` }; } const agentId = parts[1]; // Defence-in-depth: agentId becomes a path segment under // ~/.openclaw/agents/. Reject anything that could escape that root // (".." segments, slashes, NULs, etc.) before touching the FS. if (!SAFE_AGENT_ID.test(agentId)) { return { success: false, error: `Invalid agentId: ${agentId}` }; } const openclawConfigDir = getOpenClawConfigDir(); const sessionsDir = join(openclawConfigDir, 'agents', agentId, 'sessions'); const sessionsJsonPath = join(sessionsDir, 'sessions.json'); logger.info(`[session:delete] key=${sessionKey} agentId=${agentId}`); logger.info(`[session:delete] sessionsJson=${sessionsJsonPath}`); const fsP = await import('fs/promises'); // ── Step 1: read sessions.json to find the UUID file for this sessionKey ── let sessionsJson: Record = {}; try { const raw = await fsP.readFile(sessionsJsonPath, 'utf8'); sessionsJson = JSON.parse(raw) as Record; } catch (e) { logger.warn(`[session:delete] Could not read sessions.json: ${String(e)}`); return { success: false, error: `Could not read sessions.json: ${String(e)}` }; } const resolution = resolveSessionTranscriptPath(sessionsJson, sessionsDir, sessionKey); if (!resolution.ok) { if (resolution.failure.kind === 'not-found') { const rawVal = sessionsJson[sessionKey]; logger.warn(`[session:delete] Cannot resolve file for "${sessionKey}". Raw value: ${JSON.stringify(rawVal)}`); return { success: false, error: `Cannot resolve file for session: ${sessionKey}` }; } logger.warn(`[session:delete] Refusing to delete out-of-scope path for "${sessionKey}": ${resolution.failure.resolvedPath}`); return { success: false, error: `Resolved session path is outside the agent sessions dir: ${resolution.failure.resolvedPath}` }; } const { resolvedSrcPath, sessionsDirAbs, baseId } = resolution; logger.info(`[session:delete] file: ${resolvedSrcPath}`); // ── Step 2: hard-delete the JSONL transcript and its siblings ── const sweep = await sweepSessionArtefacts(sessionsDirAbs, baseId); for (const removedPath of sweep.removed) { logger.info(`[session:delete] Unlinked ${removedPath}`); } for (const { path: failedPath, error } of sweep.errors) { logger.warn(`[session:delete] Failed to unlink ${failedPath}: ${String(error)}`); } logger.info(`[session:delete] Hard-deleted ${sweep.removed.length} file(s) for ${baseId}`); // ── Step 3: remove the entry from sessions.json ── try { // Re-read to avoid race conditions const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8'); const json2 = JSON.parse(raw2) as Record; removeSessionEntry(json2, sessionKey); await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8'); logger.info(`[session:delete] Removed "${sessionKey}" from sessions.json`); } catch (e) { logger.warn(`[session:delete] Could not update sessions.json: ${String(e)}`); // Non-fatal — transcript files were already unlinked. } return { success: true }; } catch (err) { logger.error(`[session:delete] Unexpected error for ${sessionKey}:`, err); return { success: false, error: String(err) }; } }); ipcMain.handle('session:rename', async (_, sessionKey: string, label: string) => { try { if (!sessionKey || !sessionKey.startsWith('agent:')) { return { success: false, error: `Invalid sessionKey: ${sessionKey}` }; } if (!label || typeof label !== 'string' || !label.trim()) { return { success: false, error: 'Label cannot be empty' }; } const parts = sessionKey.split(':'); if (parts.length < 3) { return { success: false, error: `Malformed sessionKey: ${sessionKey}` }; } const agentId = parts[1]; if (!SAFE_AGENT_ID.test(agentId)) { return { success: false, error: `Invalid agentId in sessionKey: ${agentId}` }; } const sessionsJsonPath = join( getOpenClawConfigDir(), 'agents', agentId, 'sessions', 'sessions.json', ); const raw = await fsP.readFile(sessionsJsonPath, 'utf8'); const json = JSON.parse(raw) as Record; // Update label in sessions.json — supports both object-keyed and array formats let found = false; if (json[sessionKey] && typeof json[sessionKey] === 'object') { (json[sessionKey] as Record).label = label.trim(); found = true; } if (Array.isArray(json.sessions)) { for (const entry of json.sessions as Array>) { if (entry.key === sessionKey || entry.sessionKey === sessionKey) { entry.label = label.trim(); found = true; } } } if (!found) { return { success: false, error: `Session not found in sessions.json: ${sessionKey}` }; } await fsP.writeFile(sessionsJsonPath, JSON.stringify(json, null, 2), 'utf8'); logger.info(`[session:rename] key=${sessionKey} label=${label.trim()}`); return { success: true }; } catch (err) { logger.error(`[session:rename] Unexpected error for ${sessionKey}:`, err); return { success: false, error: String(err) }; } }); } // ── File preview (sandboxed) ────────────────────────────────────────── // // IPC channels backing the in-app file preview / overlay components. // Reads, writes, dir listings and tree scans are restricted to a small // allowlist of roots so the renderer can never reach arbitrary disk paths // (defence in depth on top of contextIsolation). const FILE_PREVIEW_MAX_TEXT_BYTES = 2 * 1024 * 1024; // 2 MB // Binary preview ceiling for inline PDF / spreadsheet rendering. Anything // over this still falls back to "open with system app" via the existing // confirmAndOpenFile flow so we never balloon the renderer with huge // buffers, but typical work-product PDFs / XLSX files (a few MB) sail // through. const FILE_PREVIEW_MAX_BINARY_BYTES = 50 * 1024 * 1024; // 50 MB const FILE_PREVIEW_TREE_MAX_DEPTH = 6; const FILE_PREVIEW_TREE_MAX_NODES = 5000; const FILE_PREVIEW_DIR_BLACKLIST = new Set([ 'node_modules', '.venv', '__pycache__', '.git', 'dist', 'build', '.next', '.turbo', '.cache', ]); interface FilePreviewTreeOptions { maxDepth?: number; maxNodes?: number; includeHidden?: boolean; } interface FilePreviewTreeNode { name: string; relPath: string; absPath: string; isDir: boolean; size?: number; mtime?: number; children?: FilePreviewTreeNode[]; } function isPathInside(child: string, parent: string): boolean { const c = resolve(child); const p = resolve(parent); // Windows file systems are case-insensitive: realpath() returns the // on-disk casing while `homedir()` / `resolve()` may preserve whatever // casing the OS reported, leading to false `outsideSandbox` rejections // (e.g. `C:\Users\Foo\.openclaw\…` vs `c:\users\foo\.openclaw\…`). // Compare case-insensitively on Windows; keep strict comparison on // POSIX so we don't accidentally widen the sandbox there. if (process.platform === 'win32') { const cl = c.toLowerCase(); const pl = p.toLowerCase(); return cl === pl || cl.startsWith(pl + sep); } return c === p || c.startsWith(p + sep); } /** * Roots inside which the file preview pipeline can READ AND WRITE. * These are the user's own data directories — modifying them is safe. */ function getFilePreviewWriteRoots(): string[] { const roots: string[] = []; const openclawDir = join(homedir(), '.openclaw'); roots.push(resolve(openclawDir)); try { roots.push(resolve(app.getPath('userData'))); } catch { // ignore — userData should always exist } roots.push(resolve(OUTBOUND_DIR)); return roots; } interface ResolvedSandboxedPath { realPath: string; /** True when the resolved path lives in a read-only-only root (e.g. bundled skill). */ readOnly: boolean; } async function resolveSandboxedPath( input: string, mode: 'read' | 'write' = 'read', ): Promise { if (typeof input !== 'string' || !input.trim()) { throw new Error('outsideSandbox'); } // OpenClaw stores agent.workspace / agentDir paths as `~/.openclaw/...` // literals; expand the tilde before realpath so sandbox resolution // matches what the user actually sees on disk. const expanded = expandPath(input); const fsP = await import('fs/promises'); let real: string; try { real = await fsP.realpath(expanded); } catch { // Path may not exist yet (e.g. write that should fail later); // resolve without realpath fallback so the sandbox check is still applied. real = resolve(expanded); } const writeRoots = getFilePreviewWriteRoots(); if (writeRoots.some((root) => isPathInside(real, root))) { return { realPath: real, readOnly: false }; } if (mode === 'write') { // Preview is broadly read-only, but mutations stay confined to the // app-owned write roots. This avoids path-specific allowlists (which // are fragile on Windows, OneDrive, localized folders, Chinese user // names, etc.) while preserving a strict write boundary. throw new Error('readOnlyRoot'); } // Read-only preview should work for any real local path surfaced by the // desktop app/runtime. `realpath()` above canonicalizes Windows casing, // Unicode path segments and symlinks; individual handlers still enforce // file-vs-directory checks, size caps, hidden directory skips and binary // detection where appropriate. return { realPath: real, readOnly: true }; } function looksLikeBinary(buf: Buffer): boolean { // Treat presence of a NUL byte in the first 8 KB as binary, matching // the heuristic used by isbinaryfile / git. const limit = Math.min(buf.length, 8192); for (let i = 0; i < limit; i += 1) { if (buf[i] === 0) return true; } return false; } function shouldSkipDirEntry(name: string, includeHidden: boolean): boolean { if (FILE_PREVIEW_DIR_BLACKLIST.has(name)) return true; if (!includeHidden && name.startsWith('.')) return true; return false; } function shouldSkipFileEntry(name: string, includeHidden: boolean): boolean { if (!includeHidden && name.startsWith('.')) return true; return false; } function registerFilePreviewHandlers(): void { ipcMain.handle('file:readText', async (_, inputPath: string) => { try { const { realPath: real, readOnly } = await resolveSandboxedPath(inputPath, 'read'); const fsP = await import('fs/promises'); const stat = await fsP.stat(real); if (!stat.isFile()) { return { ok: false, error: 'notFound' }; } if (stat.size > FILE_PREVIEW_MAX_TEXT_BYTES) { return { ok: false, error: 'tooLarge', size: stat.size }; } const buf = await fsP.readFile(real); if (looksLikeBinary(buf)) { return { ok: false, error: 'binary', size: stat.size }; } return { ok: true, content: buf.toString('utf8'), mimeType: getMimeType(extname(real)), size: stat.size, readOnly, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message === 'outsideSandbox') { return { ok: false, error: 'outsideSandbox' }; } if (message.includes('ENOENT')) { return { ok: false, error: 'notFound' }; } return { ok: false, error: message }; } }); ipcMain.handle('file:readBinary', async (_, inputPath: string, opts?: { maxBytes?: number }) => { try { const { realPath: real, readOnly } = await resolveSandboxedPath(inputPath, 'read'); const fsP = await import('fs/promises'); const stat = await fsP.stat(real); if (!stat.isFile()) { return { ok: false, error: 'notFound' }; } const cap = Math.max( 1, Math.min(opts?.maxBytes ?? FILE_PREVIEW_MAX_BINARY_BYTES, FILE_PREVIEW_MAX_BINARY_BYTES), ); if (stat.size > cap) { return { ok: false, error: 'tooLarge', size: stat.size }; } const buf = await fsP.readFile(real); // Electron serialises Node Buffers as ArrayBuffer-backed Uint8Arrays // through structured clone, so the renderer receives a Uint8Array // without the heavyweight base64 round-trip. const view = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); return { ok: true, data: view, mimeType: getMimeType(extname(real)), size: stat.size, readOnly, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message === 'outsideSandbox') { return { ok: false, error: 'outsideSandbox' }; } if (message.includes('ENOENT')) { return { ok: false, error: 'notFound' }; } return { ok: false, error: message }; } }); ipcMain.handle('file:writeText', async (_, inputPath: string, content: string) => { try { if (typeof content !== 'string') { return { ok: false, error: 'invalidContent' }; } if (Buffer.byteLength(content, 'utf8') > FILE_PREVIEW_MAX_TEXT_BYTES) { return { ok: false, error: 'tooLarge' }; } const { realPath: real } = await resolveSandboxedPath(inputPath, 'write'); const fsP = await import('fs/promises'); // Only allow writing existing files to avoid surprise creation. let stat; try { stat = await fsP.stat(real); } catch { return { ok: false, error: 'notFound' }; } if (!stat.isFile()) { return { ok: false, error: 'notFound' }; } await fsP.writeFile(real, content, 'utf8'); return { ok: true }; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message === 'outsideSandbox') { return { ok: false, error: 'outsideSandbox' }; } if (message === 'readOnlyRoot') { return { ok: false, error: 'readOnlyRoot' }; } return { ok: false, error: message }; } }); ipcMain.handle('file:stat', async (_, inputPath: string) => { try { const { realPath: real, readOnly } = await resolveSandboxedPath(inputPath, 'read'); const fsP = await import('fs/promises'); const stat = await fsP.stat(real); return { ok: true, size: stat.size, mtime: stat.mtimeMs, isFile: stat.isFile(), isDir: stat.isDirectory(), readOnly, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message === 'outsideSandbox') { return { ok: false, error: 'outsideSandbox' }; } if (message.includes('ENOENT')) { return { ok: false, error: 'notFound' }; } return { ok: false, error: message }; } }); ipcMain.handle('file:listDir', async (_, inputPath: string) => { try { const { realPath: real } = await resolveSandboxedPath(inputPath, 'read'); const fsP = await import('fs/promises'); const dirents = await fsP.readdir(real, { withFileTypes: true }); const entries = await Promise.all(dirents.map(async (entry) => { const abs = join(real, entry.name); let size = 0; try { if (entry.isFile()) { size = (await fsP.stat(abs)).size; } } catch { // non-fatal } return { name: entry.name, path: abs, isDir: entry.isDirectory(), size, }; })); return { ok: true, entries }; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message === 'outsideSandbox') { return { ok: false, error: 'outsideSandbox' }; } if (message.includes('ENOENT')) { return { ok: false, error: 'notFound' }; } return { ok: false, error: message }; } }); ipcMain.handle('file:listTree', async (_, inputPath: string, opts?: FilePreviewTreeOptions) => { try { const { realPath: real } = await resolveSandboxedPath(inputPath, 'read'); const fsP = await import('fs/promises'); const stat = await fsP.stat(real); if (!stat.isDirectory()) { return { ok: false, error: 'notDirectory' }; } const maxDepth = Math.max(1, Math.min(opts?.maxDepth ?? FILE_PREVIEW_TREE_MAX_DEPTH, 12)); const maxNodes = Math.max(1, Math.min(opts?.maxNodes ?? FILE_PREVIEW_TREE_MAX_NODES, 50000)); const includeHidden = !!opts?.includeHidden; let nodeCount = 0; let truncated = false; const walk = async ( absDir: string, depth: number, ): Promise => { if (depth > maxDepth || truncated) return undefined; let dirents; try { dirents = await fsP.readdir(absDir, { withFileTypes: true }); } catch { return []; } const children: FilePreviewTreeNode[] = []; for (const entry of dirents) { if (truncated) break; const isDir = entry.isDirectory(); const isFile = entry.isFile(); if (!isDir && !isFile) continue; if (isDir && shouldSkipDirEntry(entry.name, includeHidden)) continue; if (isFile && shouldSkipFileEntry(entry.name, includeHidden)) continue; if (nodeCount >= maxNodes) { truncated = true; break; } nodeCount += 1; const abs = join(absDir, entry.name); // Normalise relPath to forward slashes for renderer use — the // renderer derives the same value cross-platform when looking // up a node by path, and Windows backslashes look out of place // in URLs / display strings. const rel = relative(real, abs).split(sep).join('/'); const node: FilePreviewTreeNode = { name: entry.name, relPath: rel, absPath: abs, isDir, }; if (isFile) { try { const fstat = await fsP.stat(abs); node.size = fstat.size; node.mtime = fstat.mtimeMs; } catch { // non-fatal } } else if (isDir) { try { const fstat = await fsP.stat(abs); node.mtime = fstat.mtimeMs; } catch { // non-fatal } node.children = await walk(abs, depth + 1) ?? []; } children.push(node); } children.sort((a, b) => { if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; return a.name.localeCompare(b.name); }); return children; }; const root: FilePreviewTreeNode = { name: basename(real) || real, relPath: '', absPath: real, isDir: true, mtime: stat.mtimeMs, children: (await walk(real, 1)) ?? [], }; return { ok: true, root, truncated }; } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message === 'outsideSandbox') { return { ok: false, error: 'outsideSandbox' }; } if (message.includes('ENOENT')) { return { ok: false, error: 'notFound' }; } return { ok: false, error: message }; } }); }