diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index b79de96b9..044da7840 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -9,10 +9,10 @@ import { EventEmitter } from 'events'; import { existsSync, writeFileSync } from 'fs'; import WebSocket from 'ws'; import { PORTS } from '../utils/config'; -import { - getOpenClawDir, - getOpenClawEntryPath, - isOpenClawBuilt, +import { + getOpenClawDir, + getOpenClawEntryPath, + isOpenClawBuilt, isOpenClawPresent, appendNodeRequireToNodeOptions, quoteForCmd, @@ -185,7 +185,7 @@ export class GatewayManager extends EventEmitter { }> = new Map(); private deviceIdentity: DeviceIdentity | null = null; private restartDebounceTimer: NodeJS.Timeout | null = null; - + constructor(config?: Partial) { super(); this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config }; @@ -244,21 +244,21 @@ export class GatewayManager extends EventEmitter { this.recentStartupStderrLines.splice(0, this.recentStartupStderrLines.length - MAX_STDERR_LINES); } } - + /** * Get current Gateway status */ getStatus(): GatewayStatus { return { ...this.status }; } - + /** * Check if Gateway is connected and ready */ isConnected(): boolean { return this.status.state === 'running' && this.ws?.readyState === WebSocket.OPEN; } - + /** * Start Gateway process */ @@ -272,7 +272,7 @@ export class GatewayManager extends EventEmitter { logger.debug('Gateway already running, skipping start'); return; } - + this.startLock = true; logger.info(`Gateway start requested (port=${this.status.port})`); this.lastSpawnSummary = null; @@ -305,7 +305,7 @@ export class GatewayManager extends EventEmitter { }).catch(err => { logger.error('Failed to check Python environment:', err); }); - + try { while (true) { this.recentStartupStderrLines = []; @@ -321,18 +321,18 @@ export class GatewayManager extends EventEmitter { this.startHealthCheck(); return; } - + logger.debug('No existing Gateway found, starting new process...'); - + // Start new Gateway process await this.startProcess(); - + // Wait for Gateway to be ready await this.waitForReady(); - + // Connect WebSocket await this.connect(this.status.port); - + // Start health monitoring this.startHealthCheck(); logger.debug('Gateway started successfully'); @@ -354,7 +354,7 @@ export class GatewayManager extends EventEmitter { throw error; } } - + } catch (error) { logger.error( `Gateway start failed (port=${this.status.port}, reconnectAttempts=${this.reconnectAttempts}, spawn=${this.lastSpawnSummary ?? 'n/a'})`, @@ -366,7 +366,7 @@ export class GatewayManager extends EventEmitter { this.startLock = false; } } - + /** * Stop Gateway process */ @@ -374,10 +374,10 @@ export class GatewayManager extends EventEmitter { logger.info('Gateway stop requested'); // Disable auto-reconnect this.shouldReconnect = false; - + // Clear all timers this.clearAllTimers(); - + // If this manager is attached to an external gateway process, ask it to shut down // over protocol before closing the socket. if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN) { @@ -393,17 +393,17 @@ export class GatewayManager extends EventEmitter { this.ws.close(1000, 'Gateway stopped by user'); this.ws = null; } - + // Kill process if (this.process && this.ownsProcess) { const child = this.process; - + await new Promise((resolve) => { // If process already exited, resolve immediately if (child.exitCode !== null || child.signalCode !== null) { return resolve(); } - + // Kill the entire process group so respawned children are also terminated. // The gateway entry script may respawn itself; killing only the parent PID // leaves the child orphaned (PPID=1) and still holding the port. @@ -413,7 +413,7 @@ export class GatewayManager extends EventEmitter { try { process.kill(-pid, 'SIGTERM'); } catch { /* group kill failed, fall back */ } } child.kill('SIGTERM'); - + // Force kill after timeout const timeout = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { @@ -430,29 +430,29 @@ export class GatewayManager extends EventEmitter { clearTimeout(timeout); resolve(); }); - + child.once('error', () => { clearTimeout(timeout); resolve(); }); }); - + if (this.process === child) { this.process = null; } } this.ownsProcess = false; - + // Reject all pending requests for (const [, request] of this.pendingRequests) { clearTimeout(request.timeout); request.reject(new Error('Gateway stopped')); } this.pendingRequests.clear(); - + this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined }); } - + /** * Restart Gateway process */ @@ -481,7 +481,7 @@ export class GatewayManager extends EventEmitter { }); }, delayMs); } - + /** * Clear all active timers */ @@ -503,7 +503,7 @@ export class GatewayManager extends EventEmitter { this.restartDebounceTimer = null; } } - + /** * Make an RPC call to the Gateway * Uses OpenClaw protocol format: { type: "req", id: "...", method: "...", params: {...} } @@ -514,22 +514,22 @@ export class GatewayManager extends EventEmitter { reject(new Error('Gateway not connected')); return; } - + const id = crypto.randomUUID(); - + // Set timeout for request const timeout = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`RPC timeout: ${method}`)); }, timeoutMs); - + // Store pending request this.pendingRequests.set(id, { resolve: resolve as (value: unknown) => void, reject, timeout, }); - + // Send request using OpenClaw protocol format const request = { type: 'req', @@ -537,7 +537,7 @@ export class GatewayManager extends EventEmitter { method, params, }; - + try { this.ws.send(JSON.stringify(request)); } catch (error) { @@ -547,7 +547,7 @@ export class GatewayManager extends EventEmitter { } }); } - + /** * Start health check monitoring */ @@ -555,12 +555,12 @@ export class GatewayManager extends EventEmitter { if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval); } - + this.healthCheckInterval = setInterval(async () => { if (this.status.state !== 'running') { return; } - + try { const health = await this.checkHealth(); if (!health.ok) { @@ -572,7 +572,7 @@ export class GatewayManager extends EventEmitter { } }, 30000); // Check every 30 seconds } - + /** * Check Gateway health via WebSocket ping * OpenClaw Gateway doesn't have an HTTP /health endpoint @@ -580,7 +580,7 @@ export class GatewayManager extends EventEmitter { async checkHealth(): Promise<{ ok: boolean; error?: string; uptime?: number }> { try { if (this.ws && this.ws.readyState === WebSocket.OPEN) { - const uptime = this.status.connectedAt + const uptime = this.status.connectedAt ? Math.floor((Date.now() - this.status.connectedAt) / 1000) : undefined; return { ok: true, uptime }; @@ -590,7 +590,7 @@ export class GatewayManager extends EventEmitter { return { ok: false, error: String(error) }; } } - + /** * Unload the system-managed openclaw gateway launchctl service if it is * loaded. Without this, killing the process only causes launchctl to @@ -654,7 +654,7 @@ export class GatewayManager extends EventEmitter { private async findExistingGateway(): Promise<{ port: number, externalToken?: string } | null> { try { const port = PORTS.OPENCLAW_GATEWAY; - + try { // Platform-specific command to find processes listening on the gateway port. // On Windows, lsof doesn't exist; use PowerShell's Get-NetTCPConnection instead. @@ -670,46 +670,46 @@ export class GatewayManager extends EventEmitter { }); }).catch(reject); }); - + if (stdout.trim()) { const pids = stdout.trim().split(/\r?\n/) .map(s => s.trim()) .filter(Boolean); - + if (pids.length > 0) { if (!this.process || !pids.includes(String(this.process.pid))) { - logger.info(`Found orphaned process listening on port ${port} (PIDs: ${pids.join(', ')}), attempting to kill...`); + logger.info(`Found orphaned process listening on port ${port} (PIDs: ${pids.join(', ')}), attempting to kill...`); - // Unload the launchctl service first so macOS doesn't auto- - // respawn the process we're about to kill. - if (process.platform === 'darwin') { - await this.unloadLaunchctlService(); - } + // Unload the launchctl service first so macOS doesn't auto- + // respawn the process we're about to kill. + if (process.platform === 'darwin') { + await this.unloadLaunchctlService(); + } - // Terminate orphaned processes - for (const pid of pids) { - try { - if (process.platform === 'win32') { - // On Windows, use taskkill for reliable process group termination - import('child_process').then(cp => { - cp.exec(`taskkill /PID ${pid} /T /F`, { timeout: 5000 }, () => {}); - }).catch(() => {}); - } else { - // SIGTERM first so the gateway can clean up its lock file. - process.kill(parseInt(pid), 'SIGTERM'); - } - } catch { /* ignore */ } - } - await new Promise(r => setTimeout(r, process.platform === 'win32' ? 2000 : 3000)); + // Terminate orphaned processes + for (const pid of pids) { + try { + if (process.platform === 'win32') { + // On Windows, use taskkill for reliable process group termination + import('child_process').then(cp => { + cp.exec(`taskkill /PID ${pid} /T /F`, { timeout: 5000 }, () => { }); + }).catch(() => { }); + } else { + // SIGTERM first so the gateway can clean up its lock file. + process.kill(parseInt(pid), 'SIGTERM'); + } + } catch { /* ignore */ } + } + await new Promise(r => setTimeout(r, process.platform === 'win32' ? 2000 : 3000)); - // SIGKILL any survivors (Unix only — Windows taskkill /F is already forceful) - if (process.platform !== 'win32') { - for (const pid of pids) { - try { process.kill(parseInt(pid), 0); process.kill(parseInt(pid), 'SIGKILL'); } catch { /* already exited */ } - } - await new Promise(r => setTimeout(r, 1000)); - } - return null; + // SIGKILL any survivors (Unix only — Windows taskkill /F is already forceful) + if (process.platform !== 'win32') { + for (const pid of pids) { + try { process.kill(parseInt(pid), 0); process.kill(parseInt(pid), 'SIGKILL'); } catch { /* already exited */ } + } + await new Promise(r => setTimeout(r, 1000)); + } + return null; } } } @@ -724,13 +724,13 @@ export class GatewayManager extends EventEmitter { testWs.close(); resolve(null); }, 2000); - + testWs.on('open', () => { clearTimeout(timeout); testWs.close(); resolve({ port }); }); - + testWs.on('error', () => { clearTimeout(timeout); resolve(null); @@ -739,7 +739,7 @@ export class GatewayManager extends EventEmitter { } catch { // Gateway not running } - + return null; } @@ -785,7 +785,7 @@ export class GatewayManager extends EventEmitter { spawnEnv['OPENCLAW_NO_RESPAWN'] = '1'; const existingNodeOpts = spawnEnv['NODE_OPTIONS'] ?? ''; if (!existingNodeOpts.includes('--disable-warning=ExperimentalWarning') && - !existingNodeOpts.includes('--no-warnings')) { + !existingNodeOpts.includes('--no-warnings')) { spawnEnv['NODE_OPTIONS'] = `${existingNodeOpts} --disable-warning=ExperimentalWarning`.trim(); } } @@ -851,7 +851,7 @@ export class GatewayManager extends EventEmitter { }); }); } - + /** * Start Gateway process * Uses OpenClaw npm package from node_modules (dev) or resources (production) @@ -862,14 +862,14 @@ export class GatewayManager extends EventEmitter { const openclawDir = getOpenClawDir(); const entryScript = getOpenClawEntryPath(); - + // Verify OpenClaw package exists if (!isOpenClawPresent()) { const errMsg = `OpenClaw package not found at: ${openclawDir}`; logger.error(errMsg); throw new Error(errMsg); } - + // Get or generate gateway token const gatewayToken = await getSetting('gatewayToken'); @@ -900,17 +900,17 @@ export class GatewayManager extends EventEmitter { } catch (err) { logger.warn('Failed to sync browser config to openclaw.json:', err); } - + let command: string; let args: string[]; let mode: 'packaged' | 'dev-built' | 'dev-pnpm'; - + // Determine the Node.js executable // In packaged Electron app, use process.execPath with ELECTRON_RUN_AS_NODE=1 // which makes the Electron binary behave as plain Node.js. // In development, use system 'node'. const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--allow-unconfigured']; - + if (app.isPackaged) { // Production: use Electron binary as Node.js via ELECTRON_RUN_AS_NODE // On macOS, use the Electron Helper binary to avoid extra dock icons @@ -948,7 +948,7 @@ export class GatewayManager extends EventEmitter { const finalPath = binPathExists ? `${binPath}${path.delimiter}${process.env.PATH || ''}` : process.env.PATH || ''; - + // Load provider API keys from storage to pass as environment variables const providerEnv: Record = {}; const providerTypes = getKeyableProviderTypes(); @@ -993,7 +993,7 @@ export class GatewayManager extends EventEmitter { `Starting Gateway process (mode=${mode}, port=${this.status.port}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount})` ); this.lastSpawnSummary = `mode=${mode}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}"`; - + return new Promise((resolve, reject) => { const spawnEnv: Record = { ...process.env, @@ -1014,7 +1014,7 @@ export class GatewayManager extends EventEmitter { // Pre-set the NODE_OPTIONS that entry.ts would have added via respawn const existingNodeOpts = spawnEnv['NODE_OPTIONS'] ?? ''; if (!existingNodeOpts.includes('--disable-warning=ExperimentalWarning') && - !existingNodeOpts.includes('--no-warnings')) { + !existingNodeOpts.includes('--no-warnings')) { spawnEnv['NODE_OPTIONS'] = `${existingNodeOpts} --disable-warning=ExperimentalWarning`.trim(); } } @@ -1046,13 +1046,13 @@ export class GatewayManager extends EventEmitter { }); const child = this.process; this.ownsProcess = true; - + child.on('error', (error) => { this.ownsProcess = false; logger.error('Gateway process spawn error:', error); reject(error); }); - + child.on('exit', (code, signal) => { const expectedExit = !this.shouldReconnect || this.status.state === 'stopped'; const level = expectedExit ? logger.info : logger.warn; @@ -1062,7 +1062,7 @@ export class GatewayManager extends EventEmitter { this.process = null; } this.emit('exit', code); - + if (this.status.state === 'running') { this.setStatus({ state: 'stopped' }); this.scheduleReconnect(); @@ -1072,7 +1072,7 @@ export class GatewayManager extends EventEmitter { child.on('close', (code, signal) => { logger.debug(`Gateway process stdio closed (${this.formatExit(code, signal)})`); }); - + // Log stderr child.stderr?.on('data', (data) => { const raw = data.toString(); @@ -1087,7 +1087,7 @@ export class GatewayManager extends EventEmitter { logger.warn(`[Gateway stderr] ${classified.normalized}`); } }); - + // Store PID if (child.pid) { logger.info(`Gateway process started (pid=${child.pid})`); @@ -1095,11 +1095,11 @@ export class GatewayManager extends EventEmitter { } else { logger.warn('Gateway process spawned but PID is undefined'); } - + resolve(); }); } - + /** * Wait for Gateway to be ready by checking if the port is accepting connections */ @@ -1113,7 +1113,7 @@ export class GatewayManager extends EventEmitter { logger.error(`Gateway process exited before ready (${this.formatExit(code, signal)})`); throw new Error(`Gateway process exited before becoming ready (${this.formatExit(code, signal)})`); } - + try { const ready = await new Promise((resolve) => { const testWs = new WebSocket(`ws://localhost:${this.status.port}/ws`); @@ -1121,19 +1121,19 @@ export class GatewayManager extends EventEmitter { testWs.close(); resolve(false); }, 2000); - + testWs.on('open', () => { clearTimeout(timeout); testWs.close(); resolve(true); }); - + testWs.on('error', () => { clearTimeout(timeout); resolve(false); }); }); - + if (ready) { logger.debug(`Gateway ready after ${i + 1} attempt(s)`); return; @@ -1141,28 +1141,28 @@ export class GatewayManager extends EventEmitter { } catch { // Gateway not ready yet } - + if (i > 0 && i % 10 === 0) { logger.debug(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`); } - + await new Promise((resolve) => setTimeout(resolve, interval)); } - + logger.error(`Gateway failed to become ready after ${retries} attempts on port ${this.status.port}`); throw new Error(`Gateway failed to start after ${retries} retries (port ${this.status.port})`); } - + /** * Connect WebSocket to Gateway */ private async connect(port: number, _externalToken?: string): Promise { logger.debug(`Connecting Gateway WebSocket (ws://localhost:${port}/ws)`); - + return new Promise((resolve, reject) => { // WebSocket URL (token will be sent in connect handshake, not URL) const wsUrl = `ws://localhost:${port}/ws`; - + this.ws = new WebSocket(wsUrl); let handshakeComplete = false; let connectId: string | null = null; @@ -1203,7 +1203,7 @@ export class GatewayManager extends EventEmitter { const err = error instanceof Error ? error : new Error(String(error)); reject(err); }; - + // Sends the connect frame using the server-issued challenge nonce. const sendConnectHandshake = async (challengeNonce: string) => { logger.debug('Sending connect handshake with challenge nonce'); @@ -1274,7 +1274,7 @@ export class GatewayManager extends EventEmitter { } }, 10000); handshakeTimeout = requestTimeout; - + this.pendingRequests.set(connectId, { resolve: (_result) => { handshakeComplete = true; @@ -1309,7 +1309,7 @@ export class GatewayManager extends EventEmitter { this.ws.on('open', () => { logger.debug('Gateway WebSocket opened, waiting for connect.challenge...'); }); - + let challengeReceived = false; this.ws.on('message', (data) => { @@ -1342,11 +1342,13 @@ export class GatewayManager extends EventEmitter { logger.debug('Failed to parse Gateway WebSocket message:', error); } }); - + this.ws.on('close', (code, reason) => { const reasonStr = reason?.toString() || 'unknown'; logger.warn(`Gateway WebSocket closed (code=${code}, reason=${reasonStr}, handshake=${handshakeComplete ? 'ok' : 'pending'})`); if (!handshakeComplete) { + // If the socket closes before the handshake completes, it usually means the server is still starting or restarting. + // Rejecting this promise will cause the caller (startProcess/reconnect logic) to retry cleanly. rejectOnce(new Error(`WebSocket closed before handshake: ${reasonStr}`)); return; } @@ -1356,16 +1358,21 @@ export class GatewayManager extends EventEmitter { this.scheduleReconnect(); } }); - + this.ws.on('error', (error) => { - logger.error('Gateway WebSocket error:', error); + // Suppress noisy ECONNREFUSED/WebSocket handshake errors that happen during expected Gateway restarts. + if (error.message?.includes('closed before handshake') || (error as NodeJS.ErrnoException).code === 'ECONNREFUSED') { + logger.debug(`Gateway WebSocket connection error (transient): ${error.message}`); + } else { + logger.error('Gateway WebSocket error:', error); + } if (!handshakeComplete) { rejectOnce(error); } }); }); } - + /** * Handle incoming WebSocket message */ @@ -1374,16 +1381,16 @@ export class GatewayManager extends EventEmitter { logger.debug('Received non-object Gateway message'); return; } - + const msg = message as Record; - + // Handle OpenClaw protocol response format: { type: "res", id: "...", ok: true/false, ... } if (msg.type === 'res' && typeof msg.id === 'string') { if (this.pendingRequests.has(msg.id)) { const request = this.pendingRequests.get(msg.id)!; clearTimeout(request.timeout); this.pendingRequests.delete(msg.id); - + if (msg.ok === false || msg.error) { const errorObj = msg.error as { message?: string; code?: number } | undefined; const errorMsg = errorObj?.message || JSON.stringify(msg.error) || 'Unknown error'; @@ -1394,21 +1401,21 @@ export class GatewayManager extends EventEmitter { return; } } - + // Handle OpenClaw protocol event format: { type: "event", event: "...", payload: {...} } if (msg.type === 'event' && typeof msg.event === 'string') { this.handleProtocolEvent(msg.event, msg.payload); return; } - + // Fallback: Check if this is a JSON-RPC 2.0 response (legacy support) if (isResponse(message) && message.id && this.pendingRequests.has(String(message.id))) { const request = this.pendingRequests.get(String(message.id))!; clearTimeout(request.timeout); this.pendingRequests.delete(String(message.id)); - + if (message.error) { - const errorMsg = typeof message.error === 'object' + const errorMsg = typeof message.error === 'object' ? (message.error as { message?: string }).message || JSON.stringify(message.error) : String(message.error); request.reject(new Error(errorMsg)); @@ -1417,16 +1424,16 @@ export class GatewayManager extends EventEmitter { } return; } - + // Check if this is a JSON-RPC notification (server-initiated event) if (isNotification(message)) { this.handleNotification(message); return; } - + this.emit('message', message); } - + /** * Handle OpenClaw protocol events */ @@ -1462,35 +1469,35 @@ export class GatewayManager extends EventEmitter { this.emit('notification', { method: event, params: payload }); } } - + /** * Handle server-initiated notifications */ private handleNotification(notification: JsonRpcNotification): void { this.emit('notification', notification); - + // Route specific events switch (notification.method) { case GatewayEventType.CHANNEL_STATUS_CHANGED: this.emit('channel:status', notification.params as { channelId: string; status: string }); break; - + case GatewayEventType.MESSAGE_RECEIVED: this.emit('chat:message', notification.params as { message: unknown }); break; - + case GatewayEventType.ERROR: { const errorData = notification.params as { message?: string }; this.emit('error', new Error(errorData.message || 'Gateway error')); break; } - + default: // Unknown notification type, just log it logger.debug(`Unknown Gateway notification: ${notification.method}`); } } - + /** * Start ping interval to keep connection alive */ @@ -1498,14 +1505,14 @@ export class GatewayManager extends EventEmitter { if (this.pingInterval) { clearInterval(this.pingInterval); } - + this.pingInterval = setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.ping(); } }, 30000); } - + /** * Schedule reconnection attempt with exponential backoff */ @@ -1514,35 +1521,35 @@ export class GatewayManager extends EventEmitter { logger.debug('Gateway reconnect skipped (auto-reconnect disabled)'); return; } - + if (this.reconnectTimer) { return; } - + if (this.reconnectAttempts >= this.reconnectConfig.maxAttempts) { logger.error(`Gateway reconnect failed: max attempts reached (${this.reconnectConfig.maxAttempts})`); - this.setStatus({ - state: 'error', + this.setStatus({ + state: 'error', error: 'Failed to reconnect after maximum attempts', - reconnectAttempts: this.reconnectAttempts + reconnectAttempts: this.reconnectAttempts }); return; } - + // Calculate delay with exponential backoff const delay = Math.min( this.reconnectConfig.baseDelay * Math.pow(2, this.reconnectAttempts), this.reconnectConfig.maxDelay ); - + this.reconnectAttempts++; logger.warn(`Scheduling Gateway reconnect attempt ${this.reconnectAttempts}/${this.reconnectConfig.maxAttempts} in ${delay}ms`); - - this.setStatus({ - state: 'reconnecting', - reconnectAttempts: this.reconnectAttempts + + this.setStatus({ + state: 'reconnecting', + reconnectAttempts: this.reconnectAttempts }); - + this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = null; try { @@ -1556,7 +1563,7 @@ export class GatewayManager extends EventEmitter { this.startHealthCheck(); return; } - + // Otherwise restart the process await this.startProcess(); await this.waitForReady(); @@ -1569,21 +1576,21 @@ export class GatewayManager extends EventEmitter { } }, delay); } - + /** * Update status and emit event */ private setStatus(update: Partial): void { const previousState = this.status.state; this.status = { ...this.status, ...update }; - + // Calculate uptime if connected if (this.status.state === 'running' && this.status.connectedAt) { this.status.uptime = Date.now() - this.status.connectedAt; } - + this.emit('status', this.status); - + // Log state transitions if (previousState !== this.status.state) { logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`); diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index 4e5a5216c..ff18d2456 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -516,7 +516,14 @@ function AddProviderDialog({ existingTypes, onClose, onAdd, onValidateKey }: Add // If we call add() here with undefined baseUrl, it will overwrite and erase it! // So we just fetch the latest list from the backend to update the UI. try { - await useProviderStore.getState().fetchProviders(); + const store = useProviderStore.getState(); + await store.fetchProviders(); + + // Auto-set as default if no default is currently configured + if (!store.defaultProviderId && latestRef.current.selectedType) { + // Provider type is expected to match provider ID for built-in OAuth providers + await store.setDefaultProvider(latestRef.current.selectedType); + } } catch (err) { console.error('Failed to refresh providers after OAuth:', err); }