Files
DeskClaw/electron/main/ipc-handlers.ts

1346 lines
40 KiB
TypeScript

/**
* IPC Handlers
* Registers all IPC handlers for main-renderer communication
*/
import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron';
import { existsSync } from 'node:fs';
import { GatewayManager } from '../gateway/manager';
import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub';
import {
storeApiKey,
getApiKey,
deleteApiKey,
hasApiKey,
saveProvider,
getProvider,
deleteProvider,
setDefaultProvider,
getDefaultProvider,
getAllProvidersWithKeyInfo,
type ProviderConfig,
} from '../utils/secure-storage';
import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir, ensureDir } from '../utils/paths';
import { getOpenClawCliCommand, installOpenClawCliMac } from '../utils/openclaw-cli';
import { getSetting } from '../utils/store';
import {
saveProviderKeyToOpenClaw,
removeProviderKeyFromOpenClaw,
setOpenClawDefaultModel,
setOpenClawDefaultModelWithOverride,
} from '../utils/openclaw-auth';
import { logger } from '../utils/logger';
import {
saveChannelConfig,
getChannelConfig,
getChannelFormValues,
deleteChannelConfig,
listConfiguredChannels,
setChannelEnabled,
validateChannelConfig,
validateChannelCredentials,
} from '../utils/channel-config';
import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup';
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
import { getProviderConfig } from '../utils/provider-registry';
/**
* Register all IPC handlers
*/
export function registerIpcHandlers(
gatewayManager: GatewayManager,
clawHubService: ClawHubService,
mainWindow: BrowserWindow
): void {
// Gateway handlers
registerGatewayHandlers(gatewayManager, mainWindow);
// ClawHub handlers
registerClawHubHandlers(clawHubService);
// OpenClaw handlers
registerOpenClawHandlers();
// Provider handlers
registerProviderHandlers();
// Shell handlers
registerShellHandlers();
// Dialog handlers
registerDialogHandlers();
// App handlers
registerAppHandlers();
// UV handlers
registerUvHandlers();
// Log handlers (for UI to read gateway/app logs)
registerLogHandlers();
// 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);
}
/**
* 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<string, string>;
}) => {
return updateSkillConfig(params.skillKey, {
apiKey: params.apiKey,
env: params.env,
});
});
// Get skill config
ipcMain.handle('skill:getConfig', async (_, skillKey: string) => {
return getSkillConfig(skillKey);
});
// Get all skill configs
ipcMain.handle('skill:getAllConfigs', async () => {
return 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 };
state: {
nextRunAtMs?: number;
lastRunAtMs?: number;
lastStatus?: string;
lastError?: string;
lastDurationMs?: number;
};
}
/**
* 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 || '';
// Build target from delivery info
const channelType = job.delivery?.channel || 'unknown';
const target = {
channelType,
channelId: channelType,
channelName: channelType,
};
// 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
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 data = result as { jobs?: GatewayCronJob[] };
const jobs = data?.jobs ?? [];
// 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
ipcMain.handle('cron:create', async (_, input: {
name: string;
message: string;
schedule: string;
target: { channelType: string; channelId: string; channelName: string };
enabled?: boolean;
}) => {
try {
// Transform frontend input to Gateway cron.add format
// For Discord, the recipient must be prefixed with "channel:" or "user:"
const recipientId = input.target.channelId;
const deliveryTo = input.target.channelType === 'discord' && recipientId
? `channel:${recipientId}`
: recipientId;
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: {
mode: 'announce',
channel: input.target.channelType,
to: deliveryTo,
},
};
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<string, unknown>) => {
try {
// Transform schedule string to CronSchedule object if present
const patch = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
}
// Transform message to payload format if present
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
const result = await gatewayManager.rpc('cron.update', { id, patch });
return 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;
}
});
}
/**
* 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 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 logger.listLogFiles();
});
}
/**
* Gateway-related IPC handlers
*/
function registerGatewayHandlers(
gatewayManager: GatewayManager,
mainWindow: BrowserWindow
): void {
// 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 gatewayManager.rpc(method, params, timeoutMs);
return { success: true, result };
} catch (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;
// Pass token as query param - Control UI will store it in localStorage
const url = `http://127.0.0.1:${port}/?token=${encodeURIComponent(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('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(): void {
// 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) };
}
});
// Install a system-wide openclaw command on macOS (requires admin prompt)
ipcMain.handle('openclaw:installCliMac', async () => {
return installOpenClawCliMac();
});
// ==================== Channel Configuration Handlers ====================
// Save channel configuration
ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record<string, unknown>) => {
try {
logger.info('channel:saveConfig', { channelType, keys: Object.keys(config || {}) });
saveChannelConfig(channelType, config);
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 = 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 = 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 {
deleteChannelConfig(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 = 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 {
setChannelEnabled(channelType, 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<string, string>) => {
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);
}
});
}
/**
* Provider-related IPC handlers
*/
function registerProviderHandlers(): void {
// Get all providers with key info
ipcMain.handle('provider:list', async () => {
return await getAllProvidersWithKeyInfo();
});
// Get a specific provider
ipcMain.handle('provider:get', async (_, providerId: string) => {
return await getProvider(providerId);
});
// Save a provider configuration
ipcMain.handle('provider:save', async (_, config: ProviderConfig, apiKey?: string) => {
try {
// Save the provider config
await saveProvider(config);
// Store the API key if provided
if (apiKey) {
await storeApiKey(config.id, apiKey);
// Also write to OpenClaw auth-profiles.json so the gateway can use it
try {
saveProviderKeyToOpenClaw(config.type, 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) };
}
});
// Delete a provider
ipcMain.handle('provider:delete', async (_, providerId: string) => {
try {
const existing = await getProvider(providerId);
await deleteProvider(providerId);
// Best-effort cleanup in OpenClaw auth profiles
if (existing?.type) {
try {
removeProviderKeyFromOpenClaw(existing.type);
} catch (err) {
console.warn('Failed to remove key from OpenClaw auth-profiles:', 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) => {
try {
await storeApiKey(providerId, apiKey);
// Also write to OpenClaw auth-profiles.json
// Resolve provider type from stored config, or use providerId as type
const provider = await getProvider(providerId);
const providerType = provider?.type || providerId;
try {
saveProviderKeyToOpenClaw(providerType, 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<ProviderConfig>,
apiKey?: string
) => {
const existing = await getProvider(providerId);
if (!existing) {
return { success: false, error: 'Provider not found' };
}
const previousKey = await getApiKey(providerId);
const previousProviderType = existing.type;
try {
const nextConfig: ProviderConfig = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
await saveProvider(nextConfig);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await storeApiKey(providerId, trimmedKey);
saveProviderKeyToOpenClaw(nextConfig.type, trimmedKey);
} else {
await deleteApiKey(providerId);
removeProviderKeyFromOpenClaw(nextConfig.type);
}
}
return { success: true };
} catch (error) {
// Best-effort rollback to keep config/key consistent.
try {
await saveProvider(existing);
if (previousKey) {
await storeApiKey(providerId, previousKey);
saveProviderKeyToOpenClaw(previousProviderType, previousKey);
} else {
await deleteApiKey(providerId);
removeProviderKeyFromOpenClaw(previousProviderType);
}
} 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) => {
try {
await deleteApiKey(providerId);
// Keep OpenClaw auth-profiles.json in sync with local key storage
const provider = await getProvider(providerId);
const providerType = provider?.type || providerId;
try {
removeProviderKeyFromOpenClaw(providerType);
} catch (err) {
console.warn('Failed to remove key from OpenClaw auth-profiles:', 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) => {
return await hasApiKey(providerId);
});
// Get the actual API key (for internal use only - be careful!)
ipcMain.handle('provider:getApiKey', async (_, providerId: string) => {
return await getApiKey(providerId);
});
// Set default provider and update OpenClaw default model
ipcMain.handle('provider:setDefault', async (_, providerId: string) => {
try {
await setDefaultProvider(providerId);
// Update OpenClaw config to use this provider's default model
const provider = await getProvider(providerId);
if (provider) {
try {
// If the provider has a user-specified model (e.g. siliconflow),
// build the full model string: "providerType/modelId"
const modelOverride = provider.model
? `${provider.type}/${provider.model}`
: undefined;
if (provider.type === 'custom' || provider.type === 'ollama') {
// For runtime-configured providers, use user-entered base URL/api.
setOpenClawDefaultModelWithOverride(provider.type, modelOverride, {
baseUrl: provider.baseUrl,
api: 'openai-completions',
});
} else {
setOpenClawDefaultModel(provider.type, modelOverride);
}
// Keep auth-profiles in sync with the default provider instance.
// This is especially important when multiple custom providers exist.
const providerKey = await getApiKey(providerId);
if (providerKey) {
saveProviderKeyToOpenClaw(provider.type, providerKey);
}
} 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 () => {
return await getDefaultProvider();
});
// 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 }
) => {
try {
// First try to get existing provider
const provider = await getProvider(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;
console.log(`[clawx-validate] validating provider type: ${providerType}`);
return await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl });
} catch (error) {
console.error('Validation error:', error);
return { valid: false, error: String(error) };
}
}
);
}
type ValidationProfile = 'openai-compatible' | 'google-query-key' | 'anthropic-header' | 'none';
/**
* Validate API key using lightweight model-listing endpoints (zero token cost).
* Providers are grouped into 3 auth styles:
* - openai-compatible: Bearer auth + /models
* - google-query-key: ?key=... + /models
* - anthropic-header: x-api-key + anthropic-version + /models
*/
async function validateApiKeyWithProvider(
providerType: string,
apiKey: string,
options?: { baseUrl?: string }
): Promise<{ valid: boolean; error?: string }> {
const profile = getValidationProfile(providerType);
if (profile === 'none') {
return { valid: true };
}
const trimmedKey = apiKey.trim();
if (!trimmedKey) {
return { valid: false, error: 'API key is required' };
}
try {
switch (profile) {
case 'openai-compatible':
return await validateOpenAiCompatibleKey(providerType, trimmedKey, options?.baseUrl);
case 'google-query-key':
return await validateGoogleQueryKey(providerType, trimmedKey, options?.baseUrl);
case 'anthropic-header':
return await validateAnthropicHeaderKey(providerType, trimmedKey, options?.baseUrl);
default:
return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` };
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { valid: false, error: errorMessage };
}
}
function logValidationStatus(provider: string, status: number): void {
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
}
function maskSecret(secret: string): string {
if (!secret) return '';
if (secret.length <= 8) return `${secret.slice(0, 2)}***`;
return `${secret.slice(0, 4)}***${secret.slice(-4)}`;
}
function sanitizeValidationUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);
const key = url.searchParams.get('key');
if (key) url.searchParams.set('key', maskSecret(key));
return url.toString();
} catch {
return rawUrl;
}
}
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
const next = { ...headers };
if (next.Authorization?.startsWith('Bearer ')) {
const token = next.Authorization.slice('Bearer '.length);
next.Authorization = `Bearer ${maskSecret(token)}`;
}
if (next['x-api-key']) {
next['x-api-key'] = maskSecret(next['x-api-key']);
}
return next;
}
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, '');
}
function buildOpenAiModelsUrl(baseUrl: string): string {
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
}
function logValidationRequest(
provider: string,
method: string,
url: string,
headers: Record<string, string>
): void {
console.log(
`[clawx-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}`
);
}
function getValidationProfile(providerType: string): ValidationProfile {
switch (providerType) {
case 'anthropic':
return 'anthropic-header';
case 'google':
return 'google-query-key';
case 'ollama':
return 'none';
default:
return 'openai-compatible';
}
}
async function performProviderValidationRequest(
providerLabel: string,
url: string,
headers: Record<string, string>
): Promise<{ valid: boolean; error?: string }> {
try {
logValidationRequest(providerLabel, 'GET', url, headers);
const response = await fetch(url, { headers });
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Helper: classify an HTTP response as valid / invalid / error.
* 200 / 429 → valid (key works, possibly rate-limited).
* 401 / 403 → invalid.
* Everything else → return the API error message.
*/
function classifyAuthResponse(
status: number,
data: unknown
): { valid: boolean; error?: string } {
if (status >= 200 && status < 300) return { valid: true };
if (status === 429) return { valid: true }; // rate-limited but key is valid
if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' };
// Try to extract an error message
const obj = data as { error?: { message?: string }; message?: string } | null;
const msg = obj?.error?.message || obj?.message || `API error: ${status}`;
return { valid: false, error: msg };
}
async function validateOpenAiCompatibleKey(
providerType: string,
apiKey: string,
baseUrl?: string
): Promise<{ valid: boolean; error?: string }> {
const trimmedBaseUrl = baseUrl?.trim();
if (!trimmedBaseUrl) {
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
}
const headers = { Authorization: `Bearer ${apiKey}` };
// Try /models first (standard OpenAI-compatible endpoint)
const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl);
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
// If /models returned 404, the provider likely doesn't implement it (e.g. MiniMax).
// Fall back to a minimal /chat/completions POST which almost all providers support.
if (modelsResult.error?.includes('API error: 404')) {
console.log(
`[clawx-validate] ${providerType} /models returned 404, falling back to /chat/completions probe`
);
const base = normalizeBaseUrl(trimmedBaseUrl);
const chatUrl = `${base}/chat/completions`;
return await performChatCompletionsProbe(providerType, chatUrl, headers);
}
return modelsResult;
}
/**
* Fallback validation: send a minimal /chat/completions request.
* We intentionally use max_tokens=1 to minimise cost. The goal is only to
* distinguish auth errors (401/403) from a working key (200/400/429).
* A 400 "invalid model" still proves the key itself is accepted.
*/
async function performChatCompletionsProbe(
providerLabel: string,
url: string,
headers: Record<string, string>
): Promise<{ valid: boolean; error?: string }> {
try {
logValidationRequest(providerLabel, 'POST', url, headers);
const response = await fetch(url, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'validation-probe',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 1,
}),
});
logValidationStatus(providerLabel, response.status);
const data = await response.json().catch(() => ({}));
// 401/403 → invalid key
if (response.status === 401 || response.status === 403) {
return { valid: false, error: 'Invalid API key' };
}
// 200, 400 (bad model but key accepted), 429 → key is valid
if (
(response.status >= 200 && response.status < 300) ||
response.status === 400 ||
response.status === 429
) {
return { valid: true };
}
return classifyAuthResponse(response.status, data);
} catch (error) {
return {
valid: false,
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function validateGoogleQueryKey(
providerType: string,
apiKey: string,
baseUrl?: string
): Promise<{ valid: boolean; error?: string }> {
const trimmedBaseUrl = baseUrl?.trim();
if (!trimmedBaseUrl) {
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
}
const base = normalizeBaseUrl(trimmedBaseUrl);
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
return await performProviderValidationRequest(providerType, url, {});
}
async function validateAnthropicHeaderKey(
providerType: string,
apiKey: string,
baseUrl?: string
): Promise<{ valid: boolean; error?: string }> {
const base = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
const url = `${base}/models?limit=1`;
const headers = {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
};
return await performProviderValidationRequest(providerType, url, headers);
}
/**
* Shell-related IPC handlers
*/
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(path);
});
// Open path
ipcMain.handle('shell:openPath', async (_, path: string) => {
return await shell.openPath(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: 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: 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: 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: 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<typeof app.getPath>[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();
});
}
/**
* Window control handlers (for custom title bar on Windows/Linux)
*/
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();
});
}