From 2c5c82bb7492700b04de7414c54d754e72a39402 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:10:42 +0800 Subject: [PATCH] Refactor clawx (#344) Co-authored-by: ashione --- electron/api/context.ts | 11 + electron/api/event-bus.ts | 36 + electron/api/route-utils.ts | 39 + electron/api/routes/app.ts | 32 + electron/api/routes/channels.ts | 167 ++ electron/api/routes/cron.ts | 168 ++ electron/api/routes/files.ts | 200 ++ electron/api/routes/gateway.ts | 129 ++ electron/api/routes/logs.ts | 29 + electron/api/routes/providers.ts | 295 +++ electron/api/routes/sessions.ts | 96 + electron/api/routes/settings.ts | 98 + electron/api/routes/skills.ts | 90 + electron/api/routes/usage.ts | 20 + electron/api/server.ts | 60 + electron/gateway/config-sync.ts | 182 ++ electron/gateway/connection-monitor.ts | 59 + electron/gateway/event-dispatch.ts | 63 + electron/gateway/lifecycle-controller.ts | 31 + electron/gateway/manager.ts | 1609 +++-------------- electron/gateway/process-launcher.ts | 180 ++ electron/gateway/process-policy.ts | 59 + electron/gateway/request-store.ts | 42 + electron/gateway/restart-controller.ts | 91 + electron/gateway/startup-orchestrator.ts | 106 ++ electron/gateway/startup-recovery.ts | 39 + electron/gateway/startup-stderr.ts | 42 + electron/gateway/state.ts | 38 + electron/gateway/supervisor.ts | 348 ++++ electron/gateway/ws-client.ts | 318 ++++ electron/main/index.ts | 100 +- electron/main/ipc-handlers.ts | 838 +-------- .../services/providers/provider-migration.ts | 35 + .../providers/provider-runtime-sync.ts | 460 +++++ .../services/providers/provider-service.ts | 168 ++ electron/services/providers/provider-store.ts | 103 ++ .../services/providers/provider-validation.ts | 238 +++ electron/services/providers/store-instance.ts | 23 + electron/services/secrets/secret-store.ts | 82 + electron/shared/providers/registry.ts | 293 +++ electron/shared/providers/types.ts | 169 ++ electron/utils/browser-oauth.ts | 161 ++ electron/utils/channel-config.ts | 15 - electron/utils/config.ts | 3 + electron/utils/device-oauth.ts | 32 +- electron/utils/gemini-cli-oauth.ts | 738 ++++++++ electron/utils/openclaw-auth.ts | 52 +- electron/utils/provider-registry.ts | 173 +- electron/utils/secure-storage.ts | 116 +- package.json | 4 +- src/components/layout/Sidebar.tsx | 8 +- src/components/settings/ProvidersSettings.tsx | 334 ++-- src/i18n/locales/en/settings.json | 14 + src/i18n/locales/ja/settings.json | 14 + src/i18n/locales/zh/settings.json | 14 + src/lib/gateway-client.ts | 239 +++ src/lib/host-api.ts | 42 + src/lib/host-events.ts | 25 + src/lib/provider-accounts.ts | 122 ++ src/lib/providers.ts | 86 +- src/pages/Channels/index.tsx | 216 +-- src/pages/Chat/ChatInput.tsx | 26 +- src/pages/Dashboard/index.tsx | 8 +- src/pages/Settings/index.tsx | 15 +- src/pages/Setup/index.tsx | 243 ++- src/pages/Skills/index.tsx | 6 +- src/stores/channels.ts | 89 +- src/stores/chat.ts | 88 +- src/stores/cron.ts | 30 +- src/stores/gateway.ts | 315 ++-- src/stores/providers.ts | 213 ++- src/stores/settings.ts | 28 +- src/stores/skills.ts | 69 +- tests/unit/providers.test.ts | 10 +- tests/unit/stores.test.ts | 12 +- 75 files changed, 7640 insertions(+), 3106 deletions(-) create mode 100644 electron/api/context.ts create mode 100644 electron/api/event-bus.ts create mode 100644 electron/api/route-utils.ts create mode 100644 electron/api/routes/app.ts create mode 100644 electron/api/routes/channels.ts create mode 100644 electron/api/routes/cron.ts create mode 100644 electron/api/routes/files.ts create mode 100644 electron/api/routes/gateway.ts create mode 100644 electron/api/routes/logs.ts create mode 100644 electron/api/routes/providers.ts create mode 100644 electron/api/routes/sessions.ts create mode 100644 electron/api/routes/settings.ts create mode 100644 electron/api/routes/skills.ts create mode 100644 electron/api/routes/usage.ts create mode 100644 electron/api/server.ts create mode 100644 electron/gateway/config-sync.ts create mode 100644 electron/gateway/connection-monitor.ts create mode 100644 electron/gateway/event-dispatch.ts create mode 100644 electron/gateway/lifecycle-controller.ts create mode 100644 electron/gateway/process-launcher.ts create mode 100644 electron/gateway/request-store.ts create mode 100644 electron/gateway/restart-controller.ts create mode 100644 electron/gateway/startup-orchestrator.ts create mode 100644 electron/gateway/startup-stderr.ts create mode 100644 electron/gateway/state.ts create mode 100644 electron/gateway/supervisor.ts create mode 100644 electron/gateway/ws-client.ts create mode 100644 electron/services/providers/provider-migration.ts create mode 100644 electron/services/providers/provider-runtime-sync.ts create mode 100644 electron/services/providers/provider-service.ts create mode 100644 electron/services/providers/provider-store.ts create mode 100644 electron/services/providers/provider-validation.ts create mode 100644 electron/services/providers/store-instance.ts create mode 100644 electron/services/secrets/secret-store.ts create mode 100644 electron/shared/providers/registry.ts create mode 100644 electron/shared/providers/types.ts create mode 100644 electron/utils/browser-oauth.ts create mode 100644 electron/utils/gemini-cli-oauth.ts create mode 100644 src/lib/gateway-client.ts create mode 100644 src/lib/host-api.ts create mode 100644 src/lib/host-events.ts create mode 100644 src/lib/provider-accounts.ts diff --git a/electron/api/context.ts b/electron/api/context.ts new file mode 100644 index 000000000..0cdc726a7 --- /dev/null +++ b/electron/api/context.ts @@ -0,0 +1,11 @@ +import type { BrowserWindow } from 'electron'; +import type { GatewayManager } from '../gateway/manager'; +import type { ClawHubService } from '../gateway/clawhub'; +import type { HostEventBus } from './event-bus'; + +export interface HostApiContext { + gatewayManager: GatewayManager; + clawHubService: ClawHubService; + eventBus: HostEventBus; + mainWindow: BrowserWindow | null; +} diff --git a/electron/api/event-bus.ts b/electron/api/event-bus.ts new file mode 100644 index 000000000..c7e442b1a --- /dev/null +++ b/electron/api/event-bus.ts @@ -0,0 +1,36 @@ +import type { ServerResponse } from 'http'; + +type EventPayload = unknown; + +export class HostEventBus { + private readonly clients = new Set(); + + addSseClient(res: ServerResponse): void { + this.clients.add(res); + res.on('close', () => { + this.clients.delete(res); + }); + } + + emit(eventName: string, payload: EventPayload): void { + const message = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`; + for (const client of this.clients) { + try { + client.write(message); + } catch { + this.clients.delete(client); + } + } + } + + closeAll(): void { + for (const client of this.clients) { + try { + client.end(); + } catch { + // Ignore individual client close failures. + } + } + this.clients.clear(); + } +} diff --git a/electron/api/route-utils.ts b/electron/api/route-utils.ts new file mode 100644 index 000000000..a0ea42b7f --- /dev/null +++ b/electron/api/route-utils.ts @@ -0,0 +1,39 @@ +import type { IncomingMessage, ServerResponse } from 'http'; + +export async function parseJsonBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) { + return {} as T; + } + return JSON.parse(raw) as T; +} + +export function setCorsHeaders(res: ServerResponse): void { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); +} + +export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void { + setCorsHeaders(res); + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(payload)); +} + +export function sendNoContent(res: ServerResponse): void { + setCorsHeaders(res); + res.statusCode = 204; + res.end(); +} + +export function sendText(res: ServerResponse, statusCode: number, text: string): void { + setCorsHeaders(res); + res.statusCode = statusCode; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(text); +} diff --git a/electron/api/routes/app.ts b/electron/api/routes/app.ts new file mode 100644 index 000000000..7d4db9e18 --- /dev/null +++ b/electron/api/routes/app.ts @@ -0,0 +1,32 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import type { HostApiContext } from '../context'; +import { setCorsHeaders, sendNoContent } from '../route-utils'; + +export async function handleAppRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/events' && req.method === 'GET') { + setCorsHeaders(res); + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }); + res.write(': connected\n\n'); + ctx.eventBus.addSseClient(res); + // Send a current-state snapshot immediately so renderer subscribers do not + // miss lifecycle transitions that happened before the SSE connection opened. + res.write(`event: gateway:status\ndata: ${JSON.stringify(ctx.gatewayManager.getStatus())}\n\n`); + return true; + } + + if (req.method === 'OPTIONS') { + sendNoContent(res); + return true; + } + + return false; +} diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts new file mode 100644 index 000000000..aaded553c --- /dev/null +++ b/electron/api/routes/channels.ts @@ -0,0 +1,167 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { app } from 'electron'; +import { existsSync, cpSync, mkdirSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { + deleteChannelConfig, + getChannelFormValues, + listConfiguredChannels, + saveChannelConfig, + setChannelEnabled, + validateChannelConfig, + validateChannelCredentials, +} from '../../utils/channel-config'; +import { whatsAppLoginManager } from '../../utils/whatsapp-login'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; + +async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { + const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk'); + const targetManifest = join(targetDir, 'openclaw.plugin.json'); + + if (existsSync(targetManifest)) { + return { installed: true }; + } + + const candidateSources = app.isPackaged + ? [ + join(process.resourcesPath, 'openclaw-plugins', 'dingtalk'), + join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'dingtalk'), + join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'dingtalk'), + ] + : [ + join(app.getAppPath(), 'build', 'openclaw-plugins', 'dingtalk'), + join(process.cwd(), 'build', 'openclaw-plugins', 'dingtalk'), + join(__dirname, '../../../build/openclaw-plugins/dingtalk'), + ]; + + const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json'))); + if (!sourceDir) { + return { + installed: false, + warning: `Bundled DingTalk plugin mirror not found. Checked: ${candidateSources.join(' | ')}`, + }; + } + + try { + mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true }); + rmSync(targetDir, { recursive: true, force: true }); + cpSync(sourceDir, targetDir, { recursive: true, dereference: true }); + if (!existsSync(targetManifest)) { + return { installed: false, warning: 'Failed to install DingTalk plugin mirror (manifest missing).' }; + } + return { installed: true }; + } catch { + return { installed: false, warning: 'Failed to install bundled DingTalk plugin mirror' }; + } +} + +export async function handleChannelRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/channels/configured' && req.method === 'GET') { + sendJson(res, 200, { success: true, channels: await listConfiguredChannels() }); + return true; + } + + if (url.pathname === '/api/channels/config/validate' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ channelType: string }>(req); + sendJson(res, 200, { success: true, ...(await validateChannelConfig(body.channelType)) }); + } catch (error) { + sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] }); + } + return true; + } + + if (url.pathname === '/api/channels/credentials/validate' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ channelType: string; config: Record }>(req); + sendJson(res, 200, { success: true, ...(await validateChannelCredentials(body.channelType, body.config)) }); + } catch (error) { + sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] }); + } + return true; + } + + if (url.pathname === '/api/channels/whatsapp/start' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ accountId: string }>(req); + await whatsAppLoginManager.start(body.accountId); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/channels/whatsapp/cancel' && req.method === 'POST') { + try { + await whatsAppLoginManager.stop(); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/channels/config' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ channelType: string; config: Record }>(req); + if (body.channelType === 'dingtalk') { + const installResult = await ensureDingTalkPluginInstalled(); + if (!installResult.installed) { + sendJson(res, 500, { success: false, error: installResult.warning || 'DingTalk plugin install failed' }); + return true; + } + } + await saveChannelConfig(body.channelType, body.config); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/channels/config/enabled' && req.method === 'PUT') { + try { + const body = await parseJsonBody<{ channelType: string; enabled: boolean }>(req); + await setChannelEnabled(body.channelType, body.enabled); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/channels/config/') && req.method === 'GET') { + try { + const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length)); + sendJson(res, 200, { + success: true, + values: await getChannelFormValues(channelType), + }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/channels/config/') && req.method === 'DELETE') { + try { + const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length)); + await deleteChannelConfig(channelType); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + void ctx; + return false; +} diff --git a/electron/api/routes/cron.ts b/electron/api/routes/cron.ts new file mode 100644 index 000000000..0ab658a84 --- /dev/null +++ b/electron/api/routes/cron.ts @@ -0,0 +1,168 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; + +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 }; + sessionTarget?: string; + state: { + nextRunAtMs?: number; + lastRunAtMs?: number; + lastStatus?: string; + lastError?: string; + lastDurationMs?: number; + }; +} + +function transformCronJob(job: GatewayCronJob) { + const message = job.payload?.message || job.payload?.text || ''; + const channelType = job.delivery?.channel; + const target = channelType + ? { channelType, channelId: channelType, channelName: channelType } + : undefined; + 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; + const nextRun = job.state?.nextRunAtMs + ? new Date(job.state.nextRunAtMs).toISOString() + : undefined; + + return { + id: job.id, + name: job.name, + message, + schedule: job.schedule, + target, + enabled: job.enabled, + createdAt: new Date(job.createdAtMs).toISOString(), + updatedAt: new Date(job.updatedAtMs).toISOString(), + lastRun, + nextRun, + }; +} + +export async function handleCronRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/cron/jobs' && req.method === 'GET') { + try { + const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true }); + const data = result as { jobs?: GatewayCronJob[] }; + const jobs = data?.jobs ?? []; + 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 ctx.gatewayManager.rpc('cron.update', { + id: job.id, + patch: { delivery: { mode: 'none' } }, + }); + job.delivery = { mode: 'none' }; + if (job.state?.lastError?.includes('Channel is required')) { + job.state.lastError = undefined; + job.state.lastStatus = 'ok'; + } + } catch { + // ignore per-job repair failure + } + } + } + sendJson(res, 200, jobs.map(transformCronJob)); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/cron/jobs' && req.method === 'POST') { + try { + const input = await parseJsonBody<{ name: string; message: string; schedule: string; enabled?: boolean }>(req); + const result = await ctx.gatewayManager.rpc('cron.add', { + 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: 'none' }, + }); + sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'PUT') { + try { + const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length)); + const input = await parseJsonBody>(req); + 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; + } + sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id, patch })); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'DELETE') { + try { + const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length)); + sendJson(res, 200, await ctx.gatewayManager.rpc('cron.remove', { id })); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/cron/toggle' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ id: string; enabled: boolean }>(req); + sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id: body.id, patch: { enabled: body.enabled } })); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/cron/trigger' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ id: string }>(req); + sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' })); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + return false; +} diff --git a/electron/api/routes/files.ts b/electron/api/routes/files.ts new file mode 100644 index 000000000..79312134d --- /dev/null +++ b/electron/api/routes/files.ts @@ -0,0 +1,200 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { dialog, nativeImage } from 'electron'; +import crypto from 'node:crypto'; +import { extname, join } from 'node:path'; +import { homedir } from 'node:os'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; + +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', + '.css': 'text/css', + '.js': 'text/javascript', + '.ts': 'text/typescript', + '.py': 'text/x-python', +}; + +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'); + +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; + if (size.width > maxDim || size.height > maxDim) { + const resized = size.width >= size.height + ? img.resize({ width: maxDim }) + : img.resize({ height: maxDim }); + return `data:image/png;base64,${resized.toPNG().toString('base64')}`; + } + const { readFile } = await import('node:fs/promises'); + const buf = await readFile(filePath); + return `data:${mimeType};base64,${buf.toString('base64')}`; + } catch { + return null; + } +} + +export async function handleFileRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + _ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/files/stage-paths' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ filePaths: string[] }>(req); + const fsP = await import('node:fs/promises'); + await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); + const results = []; + for (const filePath of body.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 = filePath.split(/[\\/]/).pop() || 'file'; + const preview = mimeType.startsWith('image/') + ? await generateImagePreview(stagedPath, mimeType) + : null; + results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview }); + } + sendJson(res, 200, results); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/files/stage-buffer' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ base64: string; fileName: string; mimeType: string }>(req); + const fsP = await import('node:fs/promises'); + await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); + const id = crypto.randomUUID(); + const ext = extname(body.fileName) || mimeToExt(body.mimeType); + const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); + const buffer = Buffer.from(body.base64, 'base64'); + await fsP.writeFile(stagedPath, buffer); + const mimeType = body.mimeType || getMimeType(ext); + const preview = mimeType.startsWith('image/') + ? await generateImagePreview(stagedPath, mimeType) + : null; + sendJson(res, 200, { + id, + fileName: body.fileName, + mimeType, + fileSize: buffer.length, + stagedPath, + preview, + }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/files/thumbnails' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ paths: Array<{ filePath: string; mimeType: string }> }>(req); + const fsP = await import('node:fs/promises'); + const results: Record = {}; + for (const { filePath, mimeType } of body.paths) { + try { + const s = await fsP.stat(filePath); + const preview = mimeType.startsWith('image/') + ? await generateImagePreview(filePath, mimeType) + : null; + results[filePath] = { preview, fileSize: s.size }; + } catch { + results[filePath] = { preview: null, fileSize: 0 }; + } + } + sendJson(res, 200, results); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/files/save-image' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ + base64?: string; + mimeType?: string; + filePath?: string; + defaultFileName: string; + }>(req); + const ext = body.defaultFileName.includes('.') + ? body.defaultFileName.split('.').pop()! + : (body.mimeType?.split('/')[1] || 'png'); + const result = await dialog.showSaveDialog({ + defaultPath: join(homedir(), 'Downloads', body.defaultFileName), + filters: [ + { name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + if (result.canceled || !result.filePath) { + sendJson(res, 200, { success: false }); + return true; + } + const fsP = await import('node:fs/promises'); + if (body.filePath) { + await fsP.copyFile(body.filePath, result.filePath); + } else if (body.base64) { + await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64')); + } else { + sendJson(res, 400, { success: false, error: 'No image data provided' }); + return true; + } + sendJson(res, 200, { success: true, savedPath: result.filePath }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + return false; +} diff --git a/electron/api/routes/gateway.ts b/electron/api/routes/gateway.ts new file mode 100644 index 000000000..530e030d8 --- /dev/null +++ b/electron/api/routes/gateway.ts @@ -0,0 +1,129 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { PORTS } from '../../utils/config'; +import { getSetting } from '../../utils/store'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; + +export async function handleGatewayRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/app/gateway-info' && req.method === 'GET') { + const status = ctx.gatewayManager.getStatus(); + const token = await getSetting('gatewayToken'); + const port = status.port || PORTS.OPENCLAW_GATEWAY; + sendJson(res, 200, { + wsUrl: `ws://127.0.0.1:${port}/ws`, + token, + port, + }); + return true; + } + + if (url.pathname === '/api/gateway/status' && req.method === 'GET') { + sendJson(res, 200, ctx.gatewayManager.getStatus()); + return true; + } + + if (url.pathname === '/api/gateway/health' && req.method === 'GET') { + const health = await ctx.gatewayManager.checkHealth(); + sendJson(res, 200, health); + return true; + } + + if (url.pathname === '/api/gateway/start' && req.method === 'POST') { + try { + await ctx.gatewayManager.start(); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/gateway/stop' && req.method === 'POST') { + try { + await ctx.gatewayManager.stop(); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/gateway/restart' && req.method === 'POST') { + try { + await ctx.gatewayManager.restart(); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/gateway/control-ui' && req.method === 'GET') { + try { + const status = ctx.gatewayManager.getStatus(); + const token = await getSetting('gatewayToken'); + const port = status.port || PORTS.OPENCLAW_GATEWAY; + const urlValue = `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`; + sendJson(res, 200, { success: true, url: urlValue, token, port }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/chat/send-with-media' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ + sessionKey: string; + message: string; + deliver?: boolean; + idempotencyKey: string; + media?: Array<{ filePath: string; mimeType: string; fileName: string }>; + }>(req); + const VISION_MIME_TYPES = new Set([ + 'image/png', 'image/jpeg', 'image/bmp', 'image/webp', + ]); + const imageAttachments: Array<{ content: string; mimeType: string; fileName: string }> = []; + const fileReferences: string[] = []; + if (body.media && body.media.length > 0) { + const fsP = await import('node:fs/promises'); + for (const m of body.media) { + fileReferences.push(`[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`); + if (VISION_MIME_TYPES.has(m.mimeType)) { + const fileBuffer = await fsP.readFile(m.filePath); + imageAttachments.push({ + content: fileBuffer.toString('base64'), + mimeType: m.mimeType, + fileName: m.fileName, + }); + } + } + } + + const message = fileReferences.length > 0 + ? [body.message, ...fileReferences].filter(Boolean).join('\n') + : body.message; + const rpcParams: Record = { + sessionKey: body.sessionKey, + message, + deliver: body.deliver ?? false, + idempotencyKey: body.idempotencyKey, + }; + if (imageAttachments.length > 0) { + rpcParams.attachments = imageAttachments; + } + const result = await ctx.gatewayManager.rpc('chat.send', rpcParams, 120000); + sendJson(res, 200, { success: true, result }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + return false; +} diff --git a/electron/api/routes/logs.ts b/electron/api/routes/logs.ts new file mode 100644 index 000000000..6cbc039c0 --- /dev/null +++ b/electron/api/routes/logs.ts @@ -0,0 +1,29 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { logger } from '../../utils/logger'; +import type { HostApiContext } from '../context'; +import { sendJson } from '../route-utils'; + +export async function handleLogRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + _ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/logs' && req.method === 'GET') { + const tailLines = Number(url.searchParams.get('tailLines') || '100'); + sendJson(res, 200, { content: await logger.readLogFile(Number.isFinite(tailLines) ? tailLines : 100) }); + return true; + } + + if (url.pathname === '/api/logs/dir' && req.method === 'GET') { + sendJson(res, 200, { dir: logger.getLogDir() }); + return true; + } + + if (url.pathname === '/api/logs/files' && req.method === 'GET') { + sendJson(res, 200, { files: await logger.listLogFiles() }); + return true; + } + + return false; +} diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts new file mode 100644 index 000000000..94538678d --- /dev/null +++ b/electron/api/routes/providers.ts @@ -0,0 +1,295 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { + deleteApiKey, + deleteProvider, + getAllProvidersWithKeyInfo, + getApiKey, + getDefaultProvider, + getProvider, + hasApiKey, + saveProvider, + setDefaultProvider, + storeApiKey, + type ProviderConfig, +} from '../../utils/secure-storage'; +import { + getProviderConfig, +} from '../../utils/provider-registry'; +import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth'; +import { browserOAuthManager, type BrowserOAuthProviderType } from '../../utils/browser-oauth'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; +import { + syncDefaultProviderToRuntime, + syncDeletedProviderApiKeyToRuntime, + syncDeletedProviderToRuntime, + syncProviderApiKeyToRuntime, + syncSavedProviderToRuntime, + syncUpdatedProviderToRuntime, +} from '../../services/providers/provider-runtime-sync'; +import { validateApiKeyWithProvider } from '../../services/providers/provider-validation'; +import { getProviderService } from '../../services/providers/provider-service'; +import { providerAccountToConfig } from '../../services/providers/provider-store'; +import type { ProviderAccount } from '../../shared/providers/types'; + +export async function handleProviderRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + const providerService = getProviderService(); + + if (url.pathname === '/api/provider-vendors' && req.method === 'GET') { + sendJson(res, 200, await providerService.listVendors()); + return true; + } + + if (url.pathname === '/api/provider-accounts' && req.method === 'GET') { + sendJson(res, 200, await providerService.listAccounts()); + return true; + } + + if (url.pathname === '/api/provider-accounts' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req); + const account = await providerService.createAccount(body.account, body.apiKey); + await syncSavedProviderToRuntime(providerAccountToConfig(account), body.apiKey, ctx.gatewayManager); + sendJson(res, 200, { success: true, account }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') { + sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null }); + return true; + } + + if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') { + try { + const body = await parseJsonBody<{ accountId: string }>(req); + await providerService.setDefaultAccount(body.accountId); + await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') { + const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length)); + sendJson(res, 200, await providerService.getAccount(accountId)); + return true; + } + + if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') { + const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length)); + try { + const body = await parseJsonBody<{ updates: Partial; apiKey?: string }>(req); + const existing = await providerService.getAccount(accountId); + if (!existing) { + sendJson(res, 404, { success: false, error: 'Provider account not found' }); + return true; + } + const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey); + await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager); + sendJson(res, 200, { success: true, account: nextAccount }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') { + const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length)); + try { + const existing = await providerService.getAccount(accountId); + const runtimeProviderKey = existing?.vendorId === 'google' && existing.authMode === 'oauth_browser' + ? 'google-gemini-cli' + : undefined; + if (url.searchParams.get('apiKeyOnly') === '1') { + await providerService.deleteLegacyProviderApiKey(accountId); + await syncDeletedProviderApiKeyToRuntime( + existing ? providerAccountToConfig(existing) : null, + accountId, + runtimeProviderKey, + ); + sendJson(res, 200, { success: true }); + return true; + } + await providerService.deleteAccount(accountId); + await syncDeletedProviderToRuntime( + existing ? providerAccountToConfig(existing) : null, + accountId, + ctx.gatewayManager, + runtimeProviderKey, + ); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/providers' && req.method === 'GET') { + sendJson(res, 200, await getAllProvidersWithKeyInfo()); + return true; + } + + if (url.pathname === '/api/providers/default' && req.method === 'GET') { + sendJson(res, 200, { providerId: await getDefaultProvider() ?? null }); + return true; + } + + if (url.pathname === '/api/providers/default' && req.method === 'PUT') { + try { + const body = await parseJsonBody<{ providerId: string }>(req); + await setDefaultProvider(body.providerId); + await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/providers/validate' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string } }>(req); + const provider = await getProvider(body.providerId); + const providerType = provider?.type || body.providerId; + const registryBaseUrl = getProviderConfig(providerType)?.baseUrl; + const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl; + sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl })); + } catch (error) { + sendJson(res, 500, { valid: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ + provider: OAuthProviderType | BrowserOAuthProviderType; + region?: 'global' | 'cn'; + accountId?: string; + label?: string; + }>(req); + if (body.provider === 'google') { + await browserOAuthManager.startFlow(body.provider, { + accountId: body.accountId, + label: body.label, + }); + } else { + await deviceOAuthManager.startFlow(body.provider, body.region, { + accountId: body.accountId, + label: body.label, + }); + } + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') { + try { + await deviceOAuthManager.stopFlow(); + await browserOAuthManager.stopFlow(); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/providers' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req); + const config = body.config; + await saveProvider(config); + if (body.apiKey !== undefined) { + const trimmedKey = body.apiKey.trim(); + if (trimmedKey) { + await storeApiKey(config.id, trimmedKey); + await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey); + } + } + await syncSavedProviderToRuntime(config, body.apiKey, ctx.gatewayManager); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/providers/') && req.method === 'GET') { + const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length)); + if (providerId.endsWith('/api-key')) { + const actualId = providerId.slice(0, -('/api-key'.length)); + sendJson(res, 200, { apiKey: await getApiKey(actualId) }); + return true; + } + if (providerId.endsWith('/has-api-key')) { + const actualId = providerId.slice(0, -('/has-api-key'.length)); + sendJson(res, 200, { hasKey: await hasApiKey(actualId) }); + return true; + } + sendJson(res, 200, await getProvider(providerId)); + return true; + } + + if (url.pathname.startsWith('/api/providers/') && req.method === 'PUT') { + const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length)); + try { + const body = await parseJsonBody<{ updates: Partial; apiKey?: string }>(req); + const existing = await getProvider(providerId); + if (!existing) { + sendJson(res, 404, { success: false, error: 'Provider not found' }); + return true; + } + const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() }; + await saveProvider(nextConfig); + if (body.apiKey !== undefined) { + const trimmedKey = body.apiKey.trim(); + if (trimmedKey) { + await storeApiKey(providerId, trimmedKey); + await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey); + } else { + await deleteApiKey(providerId); + await syncDeletedProviderApiKeyToRuntime(existing, providerId); + } + } + await syncUpdatedProviderToRuntime(nextConfig, body.apiKey, ctx.gatewayManager); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') { + const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length)); + try { + const existing = await getProvider(providerId); + if (url.searchParams.get('apiKeyOnly') === '1') { + await deleteApiKey(providerId); + await syncDeletedProviderApiKeyToRuntime(existing, providerId); + sendJson(res, 200, { success: true }); + return true; + } + await deleteProvider(providerId); + await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + return false; +} diff --git a/electron/api/routes/sessions.ts b/electron/api/routes/sessions.ts new file mode 100644 index 000000000..ed2dfa7fc --- /dev/null +++ b/electron/api/routes/sessions.ts @@ -0,0 +1,96 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { join } from 'node:path'; +import { getOpenClawConfigDir } from '../../utils/paths'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; + +export async function handleSessionRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + _ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/sessions/delete' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ sessionKey: string }>(req); + const sessionKey = body.sessionKey; + if (!sessionKey || !sessionKey.startsWith('agent:')) { + sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` }); + return true; + } + const parts = sessionKey.split(':'); + if (parts.length < 3) { + sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` }); + return true; + } + const agentId = parts[1]; + const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions'); + const sessionsJsonPath = join(sessionsDir, 'sessions.json'); + const fsP = await import('node:fs/promises'); + const raw = await fsP.readFile(sessionsJsonPath, 'utf8'); + const sessionsJson = JSON.parse(raw) as Record; + + let uuidFileName: string | undefined; + let resolvedSrcPath: string | undefined; + if (Array.isArray(sessionsJson.sessions)) { + const entry = (sessionsJson.sessions as Array>) + .find((s) => s.key === sessionKey || s.sessionKey === sessionKey); + if (entry) { + uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined; + if (!uuidFileName && typeof entry.id === 'string') { + uuidFileName = `${entry.id}.jsonl`; + } + } + } + if (!uuidFileName && sessionsJson[sessionKey] != null) { + const val = sessionsJson[sessionKey]; + if (typeof val === 'string') { + uuidFileName = val; + } else if (typeof val === 'object' && val !== null) { + const entry = val as Record; + const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined; + if (absFile) { + if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) { + resolvedSrcPath = absFile; + } else { + uuidFileName = absFile; + } + } else { + const uuidVal = (entry.id ?? entry.sessionId) as string | undefined; + if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`; + } + } + } + if (!uuidFileName && !resolvedSrcPath) { + sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` }); + return true; + } + if (!resolvedSrcPath) { + if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`; + resolvedSrcPath = join(sessionsDir, uuidFileName!); + } + const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl'); + try { + await fsP.access(resolvedSrcPath); + await fsP.rename(resolvedSrcPath, dstPath); + } catch { + // Non-fatal; still try to update sessions.json. + } + const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8'); + const json2 = JSON.parse(raw2) as Record; + if (Array.isArray(json2.sessions)) { + json2.sessions = (json2.sessions as Array>) + .filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey); + } else if (json2[sessionKey]) { + delete json2[sessionKey]; + } + await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8'); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + return false; +} diff --git a/electron/api/routes/settings.ts b/electron/api/routes/settings.ts new file mode 100644 index 000000000..b6be41aa2 --- /dev/null +++ b/electron/api/routes/settings.ts @@ -0,0 +1,98 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { applyProxySettings } from '../../main/proxy'; +import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../../utils/store'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; + +async function handleProxySettingsChange(ctx: HostApiContext): Promise { + const settings = await getAllSettings(); + await applyProxySettings(settings); + if (ctx.gatewayManager.getStatus().state === 'running') { + await ctx.gatewayManager.restart(); + } +} + +function patchTouchesProxy(patch: Partial): boolean { + return Object.keys(patch).some((key) => ( + key === 'proxyEnabled' || + key === 'proxyServer' || + key === 'proxyHttpServer' || + key === 'proxyHttpsServer' || + key === 'proxyAllServer' || + key === 'proxyBypassRules' + )); +} + +export async function handleSettingsRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/settings' && req.method === 'GET') { + sendJson(res, 200, await getAllSettings()); + return true; + } + + if (url.pathname === '/api/settings' && req.method === 'PUT') { + try { + const patch = await parseJsonBody>(req); + const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>; + for (const [key, value] of entries) { + await setSetting(key, value); + } + if (patchTouchesProxy(patch)) { + await handleProxySettingsChange(ctx); + } + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/settings/') && req.method === 'GET') { + const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings; + try { + sendJson(res, 200, { value: await getSetting(key) }); + } catch (error) { + sendJson(res, 404, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname.startsWith('/api/settings/') && req.method === 'PUT') { + const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings; + try { + const body = await parseJsonBody<{ value: AppSettings[keyof AppSettings] }>(req); + await setSetting(key, body.value); + if ( + key === 'proxyEnabled' || + key === 'proxyServer' || + key === 'proxyHttpServer' || + key === 'proxyHttpsServer' || + key === 'proxyAllServer' || + key === 'proxyBypassRules' + ) { + await handleProxySettingsChange(ctx); + } + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/settings/reset' && req.method === 'POST') { + try { + await resetSettings(); + await handleProxySettingsChange(ctx); + sendJson(res, 200, { success: true, settings: await getAllSettings() }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + return false; +} diff --git a/electron/api/routes/skills.ts b/electron/api/routes/skills.ts new file mode 100644 index 000000000..7984d5ad8 --- /dev/null +++ b/electron/api/routes/skills.ts @@ -0,0 +1,90 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { getAllSkillConfigs, updateSkillConfig } from '../../utils/skill-config'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; + +export async function handleSkillRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/skills/configs' && req.method === 'GET') { + sendJson(res, 200, await getAllSkillConfigs()); + return true; + } + + if (url.pathname === '/api/skills/config' && req.method === 'PUT') { + try { + const body = await parseJsonBody<{ + skillKey: string; + apiKey?: string; + env?: Record; + }>(req); + sendJson(res, 200, await updateSkillConfig(body.skillKey, { + apiKey: body.apiKey, + env: body.env, + })); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/clawhub/search' && req.method === 'POST') { + try { + const body = await parseJsonBody>(req); + sendJson(res, 200, { + success: true, + results: await ctx.clawHubService.search(body), + }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/clawhub/install' && req.method === 'POST') { + try { + const body = await parseJsonBody>(req); + await ctx.clawHubService.install(body); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/clawhub/uninstall' && req.method === 'POST') { + try { + const body = await parseJsonBody>(req); + await ctx.clawHubService.uninstall(body); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/clawhub/list' && req.method === 'GET') { + try { + sendJson(res, 200, { success: true, results: await ctx.clawHubService.listInstalled() }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + if (url.pathname === '/api/clawhub/open-readme' && req.method === 'POST') { + try { + const body = await parseJsonBody<{ slug: string }>(req); + await ctx.clawHubService.openSkillReadme(body.slug); + sendJson(res, 200, { success: true }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + + return false; +} diff --git a/electron/api/routes/usage.ts b/electron/api/routes/usage.ts new file mode 100644 index 000000000..d72be8d3b --- /dev/null +++ b/electron/api/routes/usage.ts @@ -0,0 +1,20 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import { getRecentTokenUsageHistory } from '../../utils/token-usage'; +import type { HostApiContext } from '../context'; +import { sendJson } from '../route-utils'; + +export async function handleUsageRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + _ctx: HostApiContext, +): Promise { + if (url.pathname === '/api/usage/recent-token-history' && req.method === 'GET') { + const parsedLimit = Number(url.searchParams.get('limit') || ''); + const limit = Number.isFinite(parsedLimit) ? Math.max(Math.floor(parsedLimit), 1) : undefined; + sendJson(res, 200, await getRecentTokenUsageHistory(limit)); + return true; + } + + return false; +} diff --git a/electron/api/server.ts b/electron/api/server.ts new file mode 100644 index 000000000..5fe1f3564 --- /dev/null +++ b/electron/api/server.ts @@ -0,0 +1,60 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { PORTS } from '../utils/config'; +import { logger } from '../utils/logger'; +import type { HostApiContext } from './context'; +import { handleAppRoutes } from './routes/app'; +import { handleGatewayRoutes } from './routes/gateway'; +import { handleSettingsRoutes } from './routes/settings'; +import { handleProviderRoutes } from './routes/providers'; +import { handleChannelRoutes } from './routes/channels'; +import { handleLogRoutes } from './routes/logs'; +import { handleUsageRoutes } from './routes/usage'; +import { handleSkillRoutes } from './routes/skills'; +import { handleFileRoutes } from './routes/files'; +import { handleSessionRoutes } from './routes/sessions'; +import { handleCronRoutes } from './routes/cron'; +import { sendJson } from './route-utils'; + +type RouteHandler = ( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +) => Promise; + +const routeHandlers: RouteHandler[] = [ + handleAppRoutes, + handleGatewayRoutes, + handleSettingsRoutes, + handleProviderRoutes, + handleChannelRoutes, + handleSkillRoutes, + handleFileRoutes, + handleSessionRoutes, + handleCronRoutes, + handleLogRoutes, + handleUsageRoutes, +]; + +export function startHostApiServer(ctx: HostApiContext, port = PORTS.CLAWX_HOST_API): Server { + const server = createServer(async (req, res) => { + try { + const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`); + for (const handler of routeHandlers) { + if (await handler(req, res, requestUrl, ctx)) { + return; + } + } + sendJson(res, 404, { success: false, error: `No route for ${req.method} ${requestUrl.pathname}` }); + } catch (error) { + logger.error('Host API request failed:', error); + sendJson(res, 500, { success: false, error: String(error) }); + } + }); + + server.listen(port, '127.0.0.1', () => { + logger.info(`Host API server listening on http://127.0.0.1:${port}`); + }); + + return server; +} diff --git a/electron/gateway/config-sync.ts b/electron/gateway/config-sync.ts new file mode 100644 index 000000000..6dadb74ea --- /dev/null +++ b/electron/gateway/config-sync.ts @@ -0,0 +1,182 @@ +import { app } from 'electron'; +import path from 'path'; +import { existsSync } from 'fs'; +import { getAllSettings } from '../utils/store'; +import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage'; +import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry'; +import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths'; +import { getUvMirrorEnv } from '../utils/uv-env'; +import { listConfiguredChannels } from '../utils/channel-config'; +import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth'; +import { buildProxyEnv, resolveProxySettings } from '../utils/proxy'; +import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; +import { logger } from '../utils/logger'; + +export interface GatewayLaunchContext { + appSettings: Awaited>; + openclawDir: string; + entryScript: string; + gatewayArgs: string[]; + forkEnv: Record; + mode: 'dev' | 'packaged'; + binPathExists: boolean; + loadedProviderKeyCount: number; + proxySummary: string; + channelStartupSummary: string; +} + +export async function syncGatewayConfigBeforeLaunch( + appSettings: Awaited>, +): Promise { + await syncProxyConfigToOpenClaw(appSettings); + + try { + await sanitizeOpenClawConfig(); + } catch (err) { + logger.warn('Failed to sanitize openclaw.json:', err); + } + + try { + await syncGatewayTokenToConfig(appSettings.gatewayToken); + } catch (err) { + logger.warn('Failed to sync gateway token to openclaw.json:', err); + } + + try { + await syncBrowserConfigToOpenClaw(); + } catch (err) { + logger.warn('Failed to sync browser config to openclaw.json:', err); + } +} + +async function loadProviderEnv(): Promise<{ providerEnv: Record; loadedProviderKeyCount: number }> { + const providerEnv: Record = {}; + const providerTypes = getKeyableProviderTypes(); + let loadedProviderKeyCount = 0; + + try { + const defaultProviderId = await getDefaultProvider(); + if (defaultProviderId) { + const defaultProvider = await getProvider(defaultProviderId); + const defaultProviderType = defaultProvider?.type; + const defaultProviderKey = await getApiKey(defaultProviderId); + if (defaultProviderType && defaultProviderKey) { + const envVar = getProviderEnvVar(defaultProviderType); + if (envVar) { + providerEnv[envVar] = defaultProviderKey; + loadedProviderKeyCount++; + } + } + } + } catch (err) { + logger.warn('Failed to load default provider key for environment injection:', err); + } + + for (const providerType of providerTypes) { + try { + const key = await getApiKey(providerType); + if (key) { + const envVar = getProviderEnvVar(providerType); + if (envVar) { + providerEnv[envVar] = key; + loadedProviderKeyCount++; + } + } + } catch (err) { + logger.warn(`Failed to load API key for ${providerType}:`, err); + } + } + + return { providerEnv, loadedProviderKeyCount }; +} + +async function resolveChannelStartupPolicy(): Promise<{ + skipChannels: boolean; + channelStartupSummary: string; +}> { + try { + const configuredChannels = await listConfiguredChannels(); + if (configuredChannels.length === 0) { + return { + skipChannels: true, + channelStartupSummary: 'skipped(no configured channels)', + }; + } + + return { + skipChannels: false, + channelStartupSummary: `enabled(${configuredChannels.join(',')})`, + }; + } catch (error) { + logger.warn('Failed to determine configured channels for gateway launch:', error); + return { + skipChannels: false, + channelStartupSummary: 'enabled(unknown)', + }; + } +} + +export async function prepareGatewayLaunchContext(port: number): Promise { + const openclawDir = getOpenClawDir(); + const entryScript = getOpenClawEntryPath(); + + if (!isOpenClawPresent()) { + throw new Error(`OpenClaw package not found at: ${openclawDir}`); + } + + const appSettings = await getAllSettings(); + await syncGatewayConfigBeforeLaunch(appSettings); + + if (!existsSync(entryScript)) { + throw new Error(`OpenClaw entry script not found at: ${entryScript}`); + } + + const gatewayArgs = ['gateway', '--port', String(port), '--token', appSettings.gatewayToken, '--allow-unconfigured']; + const mode = app.isPackaged ? 'packaged' : 'dev'; + + const platform = process.platform; + const arch = process.arch; + const target = `${platform}-${arch}`; + const binPath = app.isPackaged + ? path.join(process.resourcesPath, 'bin') + : path.join(process.cwd(), 'resources', 'bin', target); + const binPathExists = existsSync(binPath); + const finalPath = binPathExists + ? `${binPath}${path.delimiter}${process.env.PATH || ''}` + : process.env.PATH || ''; + + const { providerEnv, loadedProviderKeyCount } = await loadProviderEnv(); + const { skipChannels, channelStartupSummary } = await resolveChannelStartupPolicy(); + const uvEnv = await getUvMirrorEnv(); + const proxyEnv = buildProxyEnv(appSettings); + const resolvedProxy = resolveProxySettings(appSettings); + const proxySummary = appSettings.proxyEnabled + ? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}` + : 'disabled'; + + const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env; + const forkEnv: Record = { + ...baseEnv, + PATH: finalPath, + ...providerEnv, + ...uvEnv, + ...proxyEnv, + OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken, + OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '', + CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '', + OPENCLAW_NO_RESPAWN: '1', + }; + + return { + appSettings, + openclawDir, + entryScript, + gatewayArgs, + forkEnv, + mode, + binPathExists, + loadedProviderKeyCount, + proxySummary, + channelStartupSummary, + }; +} diff --git a/electron/gateway/connection-monitor.ts b/electron/gateway/connection-monitor.ts new file mode 100644 index 000000000..56afe75c4 --- /dev/null +++ b/electron/gateway/connection-monitor.ts @@ -0,0 +1,59 @@ +import { logger } from '../utils/logger'; + +type HealthResult = { ok: boolean; error?: string }; + +export class GatewayConnectionMonitor { + private pingInterval: NodeJS.Timeout | null = null; + private healthCheckInterval: NodeJS.Timeout | null = null; + + startPing(sendPing: () => void, intervalMs = 30000): void { + if (this.pingInterval) { + clearInterval(this.pingInterval); + } + + this.pingInterval = setInterval(() => { + sendPing(); + }, intervalMs); + } + + startHealthCheck(options: { + shouldCheck: () => boolean; + checkHealth: () => Promise; + onUnhealthy: (errorMessage: string) => void; + onError: (error: unknown) => void; + intervalMs?: number; + }): void { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + } + + this.healthCheckInterval = setInterval(async () => { + if (!options.shouldCheck()) { + return; + } + + try { + const health = await options.checkHealth(); + if (!health.ok) { + const errorMessage = health.error ?? 'Health check failed'; + logger.warn(`Gateway health check failed: ${errorMessage}`); + options.onUnhealthy(errorMessage); + } + } catch (error) { + logger.error('Gateway health check error:', error); + options.onError(error); + } + }, options.intervalMs ?? 30000); + } + + clear(): void { + if (this.pingInterval) { + clearInterval(this.pingInterval); + this.pingInterval = null; + } + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; + } + } +} diff --git a/electron/gateway/event-dispatch.ts b/electron/gateway/event-dispatch.ts new file mode 100644 index 000000000..c5af9da7e --- /dev/null +++ b/electron/gateway/event-dispatch.ts @@ -0,0 +1,63 @@ +import { GatewayEventType, type JsonRpcNotification } from './protocol'; +import { logger } from '../utils/logger'; + +type GatewayEventEmitter = { + emit: (event: string, payload: unknown) => boolean; +}; + +export function dispatchProtocolEvent( + emitter: GatewayEventEmitter, + event: string, + payload: unknown, +): void { + switch (event) { + case 'tick': + break; + case 'chat': + emitter.emit('chat:message', { message: payload }); + break; + case 'agent': { + const p = payload as Record; + const data = (p.data && typeof p.data === 'object') ? p.data as Record : {}; + const chatEvent: Record = { + ...data, + runId: p.runId ?? data.runId, + sessionKey: p.sessionKey ?? data.sessionKey, + state: p.state ?? data.state, + message: p.message ?? data.message, + }; + if (chatEvent.state || chatEvent.message) { + emitter.emit('chat:message', { message: chatEvent }); + } + emitter.emit('notification', { method: event, params: payload }); + break; + } + case 'channel.status': + emitter.emit('channel:status', payload as { channelId: string; status: string }); + break; + default: + emitter.emit('notification', { method: event, params: payload }); + } +} + +export function dispatchJsonRpcNotification( + emitter: GatewayEventEmitter, + notification: JsonRpcNotification, +): void { + emitter.emit('notification', notification); + switch (notification.method) { + case GatewayEventType.CHANNEL_STATUS_CHANGED: + emitter.emit('channel:status', notification.params as { channelId: string; status: string }); + break; + case GatewayEventType.MESSAGE_RECEIVED: + emitter.emit('chat:message', notification.params as { message: unknown }); + break; + case GatewayEventType.ERROR: { + const errorData = notification.params as { message?: string }; + emitter.emit('error', new Error(errorData.message || 'Gateway error')); + break; + } + default: + logger.debug(`Unknown Gateway notification: ${notification.method}`); + } +} diff --git a/electron/gateway/lifecycle-controller.ts b/electron/gateway/lifecycle-controller.ts new file mode 100644 index 000000000..9ee33066b --- /dev/null +++ b/electron/gateway/lifecycle-controller.ts @@ -0,0 +1,31 @@ +import { logger } from '../utils/logger'; +import { isLifecycleSuperseded, nextLifecycleEpoch } from './process-policy'; + +export class LifecycleSupersededError extends Error { + constructor(message: string) { + super(message); + this.name = 'LifecycleSupersededError'; + } +} + +export class GatewayLifecycleController { + private epoch = 0; + + getCurrentEpoch(): number { + return this.epoch; + } + + bump(reason: string): number { + this.epoch = nextLifecycleEpoch(this.epoch); + logger.debug(`Gateway lifecycle epoch advanced to ${this.epoch} (${reason})`); + return this.epoch; + } + + assert(expectedEpoch: number, phase: string): void { + if (isLifecycleSuperseded(expectedEpoch, this.epoch)) { + throw new LifecycleSupersededError( + `Gateway ${phase} superseded (expectedEpoch=${expectedEpoch}, currentEpoch=${this.epoch})`, + ); + } + } +} diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 94507457b..82255f894 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -2,48 +2,49 @@ * Gateway Process Manager * Manages the OpenClaw Gateway process lifecycle */ -import { app, utilityProcess } from 'electron'; +import { app } from 'electron'; import path from 'path'; import { EventEmitter } from 'events'; -import { existsSync, writeFileSync } from 'fs'; import WebSocket from 'ws'; import { PORTS } from '../utils/config'; -import { - getOpenClawDir, - getOpenClawEntryPath, - isOpenClawPresent, - appendNodeRequireToNodeOptions, -} from '../utils/paths'; -import { getAllSettings, getSetting } from '../utils/store'; -import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage'; -import { getProviderEnvVars, getKeyableProviderTypes } from '../utils/provider-registry'; -import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol'; +import { JsonRpcNotification, isNotification, isResponse } from './protocol'; import { logger } from '../utils/logger'; -import { getUvMirrorEnv } from '../utils/uv-env'; -import { isPythonReady, setupManagedPython } from '../utils/uv-setup'; import { loadOrCreateDeviceIdentity, - signDevicePayload, - publicKeyRawBase64UrlFromPem, - buildDeviceAuthPayload, type DeviceIdentity, } from '../utils/device-identity'; -import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth'; -import { buildProxyEnv, resolveProxySettings } from '../utils/proxy'; -import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; -import { shouldAttemptConfigAutoRepair } from './startup-recovery'; import { + DEFAULT_RECONNECT_CONFIG, + type ReconnectConfig, type GatewayLifecycleState, - getDeferredRestartAction, + getReconnectScheduleDecision, getReconnectSkipReason, - isLifecycleSuperseded, - nextLifecycleEpoch, - shouldDeferRestart, } from './process-policy'; +import { + clearPendingGatewayRequests, + rejectPendingGatewayRequest, + resolvePendingGatewayRequest, + type PendingGatewayRequest, +} from './request-store'; +import { dispatchJsonRpcNotification, dispatchProtocolEvent } from './event-dispatch'; +import { GatewayStateController } from './state'; +import { prepareGatewayLaunchContext } from './config-sync'; +import { connectGatewaySocket, waitForGatewayReady } from './ws-client'; +import { + findExistingGatewayProcess, + runOpenClawDoctorRepair, + terminateOwnedGatewayProcess, + unloadLaunchctlGatewayService, + waitForPortFree, + warmupManagedPythonReadiness, +} from './supervisor'; +import { GatewayConnectionMonitor } from './connection-monitor'; +import { GatewayLifecycleController, LifecycleSupersededError } from './lifecycle-controller'; +import { launchGatewayProcess } from './process-launcher'; +import { GatewayRestartController } from './restart-controller'; +import { classifyGatewayStderrMessage, recordGatewayStartupStderrLine } from './startup-stderr'; +import { runGatewayStartupSequence } from './startup-orchestrator'; -/** - * Gateway connection status - */ export interface GatewayStatus { state: GatewayLifecycleState; port: number; @@ -68,140 +69,6 @@ export interface GatewayManagerEvents { 'chat:message': (data: { message: unknown }) => void; } -/** - * Reconnection configuration - */ -interface ReconnectConfig { - maxAttempts: number; - baseDelay: number; - maxDelay: number; -} - -const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = { - maxAttempts: 10, - baseDelay: 1000, - maxDelay: 30000, -}; - -// getNodeExecutablePath() removed: utilityProcess.fork() handles process isolation -// natively on all platforms (no dock icon on macOS, no console on Windows). - -/** - * Ensure the gateway fetch-preload script exists in userData and return - * its absolute path. The script patches globalThis.fetch to inject - * ClawX app-attribution headers (HTTP-Referer, X-Title) for OpenRouter - * API requests, overriding the OpenClaw runner's hardcoded defaults. - * - * Inlined here so it works in dev, packaged, and asar modes without - * extra build config. Loaded by the Gateway child process via - * NODE_OPTIONS --require. - */ -const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict'; -(function () { - var _f = globalThis.fetch; - if (typeof _f !== 'function') return; - if (globalThis.__clawxFetchPatched) return; - globalThis.__clawxFetchPatched = true; - - globalThis.fetch = function clawxFetch(input, init) { - var url = - typeof input === 'string' ? input - : input && typeof input === 'object' && typeof input.url === 'string' - ? input.url : ''; - - if (url.indexOf('openrouter.ai') !== -1) { - init = init ? Object.assign({}, init) : {}; - var prev = init.headers; - var flat = {}; - if (prev && typeof prev.forEach === 'function') { - prev.forEach(function (v, k) { flat[k] = v; }); - } else if (prev && typeof prev === 'object') { - Object.assign(flat, prev); - } - delete flat['http-referer']; - delete flat['HTTP-Referer']; - delete flat['x-title']; - delete flat['X-Title']; - flat['HTTP-Referer'] = 'https://claw-x.com'; - flat['X-Title'] = 'ClawX'; - init.headers = flat; - } - return _f.call(globalThis, input, init); - }; - - // Global monkey-patch for child_process to enforce windowsHide: true on Windows. - // This prevents OpenClaw's tools (e.g. Terminal, Python) from flashing black - // command boxes during AI conversations, without triggering AVs. - // - // Node child_process signatures vary: - // spawn(cmd[, args][, options]) - // exec(cmd[, options][, callback]) - // execFile(file[, args][, options][, callback]) - // *Sync variants omit the callback - // - // Strategy: scan arguments for the first plain-object (the options param). - // If found, set windowsHide on it. If absent, insert a new options object - // before any trailing callback so the signature stays valid. - if (process.platform === 'win32') { - try { - var cp = require('child_process'); - if (!cp.__clawxPatched) { - cp.__clawxPatched = true; - ['spawn', 'exec', 'execFile', 'fork', 'spawnSync', 'execSync', 'execFileSync'].forEach(function(method) { - var original = cp[method]; - if (typeof original !== 'function') return; - cp[method] = function() { - var args = Array.prototype.slice.call(arguments); - var optIdx = -1; - for (var i = 1; i < args.length; i++) { - var a = args[i]; - if (a && typeof a === 'object' && !Array.isArray(a)) { - optIdx = i; - break; - } - } - if (optIdx >= 0) { - args[optIdx].windowsHide = true; - } else { - var opts = { windowsHide: true }; - if (typeof args[args.length - 1] === 'function') { - args.splice(args.length - 1, 0, opts); - } else { - args.push(opts); - } - } - return original.apply(this, args); - }; - }); - } - } catch (e) { - // ignore - } - } -})(); -`; - -function injectMoonshotWebSearchEnv( - env: Record, - apiKey: string -): void { - // OpenClaw web_search(kimi) reads KIMI_API_KEY before provider-specific config. - env.KIMI_API_KEY = apiKey; -} - -function ensureGatewayFetchPreload(): string { - const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs'); - try { writeFileSync(dest, GATEWAY_FETCH_PRELOAD_SOURCE, 'utf-8'); } catch { /* best-effort */ } - return dest; -} - -class LifecycleSupersededError extends Error { - constructor(message: string) { - super(message); - this.name = 'LifecycleSupersededError'; - } -} - /** * Gateway Manager * Handles starting, stopping, and communicating with the OpenClaw Gateway @@ -212,30 +79,46 @@ export class GatewayManager extends EventEmitter { private ownsProcess = false; private ws: WebSocket | null = null; private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY }; + private readonly stateController: GatewayStateController; private reconnectTimer: NodeJS.Timeout | null = null; - private pingInterval: NodeJS.Timeout | null = null; - private healthCheckInterval: NodeJS.Timeout | null = null; private reconnectAttempts = 0; private reconnectConfig: ReconnectConfig; private shouldReconnect = true; private startLock = false; private lastSpawnSummary: string | null = null; private recentStartupStderrLines: string[] = []; - private pendingRequests: Map void; - reject: (error: Error) => void; - timeout: NodeJS.Timeout; - }> = new Map(); + private pendingRequests: Map = new Map(); private deviceIdentity: DeviceIdentity | null = null; - private restartDebounceTimer: NodeJS.Timeout | null = null; - private reloadDebounceTimer: NodeJS.Timeout | null = null; - private lifecycleEpoch = 0; - private deferredRestartPending = false; private restartInFlight: Promise | null = null; + private readonly connectionMonitor = new GatewayConnectionMonitor(); + private readonly lifecycleController = new GatewayLifecycleController(); + private readonly restartController = new GatewayRestartController(); + private reloadDebounceTimer: NodeJS.Timeout | null = null; private externalShutdownSupported: boolean | null = null; constructor(config?: Partial) { super(); + this.stateController = new GatewayStateController({ + emitStatus: (status) => { + this.status = status; + this.emit('status', status); + }, + onTransition: (previousState, nextState) => { + this.restartController.flushDeferredRestart( + `status:${previousState}->${nextState}`, + { + state: this.status.state, + startLock: this.startLock, + shouldReconnect: this.shouldReconnect, + }, + () => { + void this.restart().catch((error) => { + logger.warn('Deferred Gateway restart failed:', error); + }); + }, + ); + }, + }); this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config }; // Device identity is loaded lazily in start() — not in the constructor — // so that async file I/O and key generation don't block module loading. @@ -265,125 +148,18 @@ export class GatewayManager extends EventEmitter { const message = error instanceof Error ? error.message : String(error); return /unknown method:\s*shutdown/i.test(message); } - - private formatExit(code: number | null, signal: NodeJS.Signals | null): string { - if (code !== null) return `code=${code}`; - if (signal) return `signal=${signal}`; - return 'code=null signal=null'; - } - - private classifyStderrMessage(message: string): { level: 'drop' | 'debug' | 'warn'; normalized: string } { - const msg = message.trim(); - if (!msg) return { level: 'drop', normalized: msg }; - - // Known noisy lines that are not actionable for Gateway lifecycle debugging. - if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) return { level: 'drop', normalized: msg }; - if (msg.includes('closed before connect') && msg.includes('token mismatch')) return { level: 'drop', normalized: msg }; - // During renderer refresh / transport switching, loopback websocket probes can time out - // while the gateway is reloading. This is expected and not actionable. - if (msg.includes('[ws] handshake timeout') && msg.includes('remote=127.0.0.1')) { - return { level: 'debug', normalized: msg }; - } - if (msg.includes('[ws] closed before connect') && msg.includes('remote=127.0.0.1')) { - return { level: 'debug', normalized: msg }; - } - - // Downgrade frequent non-fatal noise. - if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg }; - if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg }; - if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg }; - // Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless. - if (msg.includes('NODE_OPTIONs are not supported in packaged apps')) return { level: 'debug', normalized: msg }; - - return { level: 'warn', normalized: msg }; - } - - private recordStartupStderrLine(line: string): void { - const normalized = line.trim(); - if (!normalized) return; - this.recentStartupStderrLines.push(normalized); - const MAX_STDERR_LINES = 120; - if (this.recentStartupStderrLines.length > MAX_STDERR_LINES) { - this.recentStartupStderrLines.splice(0, this.recentStartupStderrLines.length - MAX_STDERR_LINES); - } - } - - private bumpLifecycleEpoch(reason: string): number { - this.lifecycleEpoch = nextLifecycleEpoch(this.lifecycleEpoch); - logger.debug(`Gateway lifecycle epoch advanced to ${this.lifecycleEpoch} (${reason})`); - return this.lifecycleEpoch; - } - - private assertLifecycleEpoch(expectedEpoch: number, phase: string): void { - if (isLifecycleSuperseded(expectedEpoch, this.lifecycleEpoch)) { - throw new LifecycleSupersededError( - `Gateway ${phase} superseded (expectedEpoch=${expectedEpoch}, currentEpoch=${this.lifecycleEpoch})` - ); - } - } - - private isRestartDeferred(): boolean { - return shouldDeferRestart({ - state: this.status.state, - startLock: this.startLock, - }); - } - - private markDeferredRestart(reason: string): void { - if (!this.deferredRestartPending) { - logger.info( - `Deferring Gateway restart (${reason}) until startup/reconnect settles (state=${this.status.state}, startLock=${this.startLock})` - ); - } else { - logger.debug( - `Gateway restart already deferred; keeping pending request (${reason}, state=${this.status.state}, startLock=${this.startLock})` - ); - } - this.deferredRestartPending = true; - } - - private flushDeferredRestart(trigger: string): void { - const action = getDeferredRestartAction({ - hasPendingRestart: this.deferredRestartPending, - state: this.status.state, - startLock: this.startLock, - shouldReconnect: this.shouldReconnect, - }); - - if (action === 'none') return; - if (action === 'wait') { - logger.debug( - `Deferred Gateway restart still waiting (${trigger}, state=${this.status.state}, startLock=${this.startLock})` - ); - return; - } - - this.deferredRestartPending = false; - if (action === 'drop') { - logger.info( - `Dropping deferred Gateway restart (${trigger}) because lifecycle already recovered (state=${this.status.state}, shouldReconnect=${this.shouldReconnect})` - ); - return; - } - - logger.info(`Executing deferred Gateway restart now (${trigger})`); - void this.restart().catch((error) => { - logger.warn('Deferred Gateway restart failed:', error); - }); - } - /** * Get current Gateway status */ getStatus(): GatewayStatus { - return { ...this.status }; + return this.stateController.getStatus(); } /** * Check if Gateway is connected and ready */ isConnected(): boolean { - return this.status.state === 'running' && this.ws?.readyState === WebSocket.OPEN; + return this.stateController.isConnected(this.ws?.readyState === WebSocket.OPEN); } /** @@ -401,7 +177,7 @@ export class GatewayManager extends EventEmitter { } this.startLock = true; - const startEpoch = this.bumpLifecycleEpoch('start'); + const startEpoch = this.lifecycleController.bump('start'); logger.info(`Gateway start requested (port=${this.status.port})`); this.lastSpawnSummary = null; this.shouldReconnect = true; @@ -419,107 +195,58 @@ export class GatewayManager extends EventEmitter { this.reconnectAttempts = 0; this.setStatus({ state: 'starting', reconnectAttempts: 0 }); - let configRepairAttempted = false; // Check if Python environment is ready (self-healing) asynchronously. // Fire-and-forget: only needs to run once, not on every retry. - void isPythonReady().then(pythonReady => { - if (!pythonReady) { - logger.info('Python environment missing or incomplete, attempting background repair...'); - void setupManagedPython().catch(err => { - logger.error('Background Python repair failed:', err); - }); - } - }).catch(err => { - logger.error('Failed to check Python environment:', err); - }); + warmupManagedPythonReadiness(); try { - let startAttempts = 0; - const MAX_START_ATTEMPTS = 3; - - while (true) { - startAttempts++; - this.assertLifecycleEpoch(startEpoch, 'start'); - this.recentStartupStderrLines = []; - try { - // Check if Gateway is already running - logger.debug('Checking for existing Gateway...'); - const existing = await this.findExistingGateway(); - this.assertLifecycleEpoch(startEpoch, 'start/find-existing'); - if (existing) { - logger.debug(`Found existing Gateway on port ${existing.port}`); - await this.connect(existing.port, existing.externalToken); - this.assertLifecycleEpoch(startEpoch, 'start/connect-existing'); - this.ownsProcess = false; - this.setStatus({ pid: undefined }); - this.startHealthCheck(); - return; - } - - logger.debug('No existing Gateway found, starting new process...'); - - // On Windows, TCP TIME_WAIT can hold the port for up to 2 minutes - // after the previous Gateway process exits, preventing the new one - // from binding. Wait for the port to be free before proceeding. - if (process.platform === 'win32') { - await this.waitForPortFree(this.status.port); - this.assertLifecycleEpoch(startEpoch, 'start/wait-port'); - } - - // Start new Gateway process + await runGatewayStartupSequence({ + port: this.status.port, + ownedPid: this.process?.pid, + shouldWaitForPortFree: process.platform === 'win32', + resetStartupStderrLines: () => { + this.recentStartupStderrLines = []; + }, + getStartupStderrLines: () => this.recentStartupStderrLines, + assertLifecycle: (phase) => { + this.lifecycleController.assert(startEpoch, phase); + }, + findExistingGateway: async (port, ownedPid) => { + return await findExistingGatewayProcess({ port, ownedPid }); + }, + connect: async (port, externalToken) => { + await this.connect(port, externalToken); + }, + onConnectedToExistingGateway: () => { + this.ownsProcess = false; + this.setStatus({ pid: undefined }); + this.startHealthCheck(); + }, + waitForPortFree: async (port) => { + await waitForPortFree(port); + }, + startProcess: async () => { await this.startProcess(); - this.assertLifecycleEpoch(startEpoch, 'start/start-process'); - - // Wait for Gateway to be ready - await this.waitForReady(); - this.assertLifecycleEpoch(startEpoch, 'start/wait-ready'); - - // Connect WebSocket - await this.connect(this.status.port); - this.assertLifecycleEpoch(startEpoch, 'start/connect'); - - // Start health monitoring + }, + waitForReady: async (port) => { + await waitForGatewayReady({ + port, + getProcessExitCode: () => this.processExitCode, + }); + }, + onConnectedToManagedGateway: () => { this.startHealthCheck(); logger.debug('Gateway started successfully'); - return; - } catch (error) { - if (error instanceof LifecycleSupersededError) { - throw error; - } - if (shouldAttemptConfigAutoRepair(error, this.recentStartupStderrLines, configRepairAttempted)) { - configRepairAttempted = true; - logger.warn( - 'Detected invalid OpenClaw config during Gateway startup; running doctor repair before retry' - ); - const repaired = await this.runOpenClawDoctorRepair(); - if (repaired) { - logger.info('OpenClaw doctor repair completed; retrying Gateway startup'); - this.setStatus({ state: 'starting', error: undefined, reconnectAttempts: 0 }); - continue; - } - logger.error('OpenClaw doctor repair failed; not retrying Gateway startup'); - } - - // Retry on transient connect errors - const errMsg = String(error); - const isTransientError = - errMsg.includes('WebSocket closed before handshake') || - errMsg.includes('ECONNREFUSED') || - errMsg.includes('Gateway process exited before becoming ready') || - errMsg.includes('Timed out waiting for connect.challenge') || - errMsg.includes('Connect handshake timeout'); - - if (startAttempts < MAX_START_ATTEMPTS && isTransientError) { - logger.warn(`Transient start error: ${errMsg}. Retrying... (${startAttempts}/${MAX_START_ATTEMPTS})`); - await new Promise((r) => setTimeout(r, 1000)); - continue; - } - - throw error; - } - } - + }, + runDoctorRepair: async () => await runOpenClawDoctorRepair(), + onDoctorRepairSuccess: () => { + this.setStatus({ state: 'starting', error: undefined, reconnectAttempts: 0 }); + }, + delay: async (ms) => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }, + }); } catch (error) { if (error instanceof LifecycleSupersededError) { logger.debug(error.message); @@ -533,7 +260,19 @@ export class GatewayManager extends EventEmitter { throw error; } finally { this.startLock = false; - this.flushDeferredRestart('start:finally'); + this.restartController.flushDeferredRestart( + 'start:finally', + { + state: this.status.state, + startLock: this.startLock, + shouldReconnect: this.shouldReconnect, + }, + () => { + void this.restart().catch((error) => { + logger.warn('Deferred Gateway restart failed:', error); + }); + }, + ); } } @@ -542,7 +281,7 @@ export class GatewayManager extends EventEmitter { */ async stop(): Promise { logger.info('Gateway stop requested'); - this.bumpLifecycleEpoch('stop'); + this.lifecycleController.bump('stop'); // Disable auto-reconnect this.shouldReconnect = false; @@ -574,34 +313,7 @@ export class GatewayManager extends EventEmitter { // Kill process if (this.process && this.ownsProcess) { const child = this.process; - // UtilityProcess doesn't expose exitCode/signalCode — track exit via event. - let exited = false; - - await new Promise((resolve) => { - child.once('exit', () => { - exited = true; - resolve(); - }); - - const pid = child.pid; - logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`); - try { child.kill(); } catch { /* ignore if already exited */ } - - // Force kill after timeout via OS-level kill on the PID - const timeout = setTimeout(() => { - if (!exited) { - logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`); - if (pid) { - try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ } - } - } - resolve(); - }, 5000); - - child.once('exit', () => { - clearTimeout(timeout); - }); - }); + await terminateOwnedGatewayProcess(child); if (this.process === child) { this.process = null; @@ -609,14 +321,9 @@ export class GatewayManager extends EventEmitter { } 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(); + clearPendingGatewayRequests(this.pendingRequests, new Error('Gateway stopped')); - this.deferredRestartPending = false; + this.restartController.resetDeferredRestart(); this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined }); } @@ -624,8 +331,14 @@ export class GatewayManager extends EventEmitter { * Restart Gateway process */ async restart(): Promise { - if (this.isRestartDeferred()) { - this.markDeferredRestart('restart'); + if (this.restartController.isRestartDeferred({ + state: this.status.state, + startLock: this.startLock, + })) { + this.restartController.markDeferredRestart('restart', { + state: this.status.state, + startLock: this.startLock, + }); return; } @@ -645,7 +358,19 @@ export class GatewayManager extends EventEmitter { await this.restartInFlight; } finally { this.restartInFlight = null; - this.flushDeferredRestart('restart:finally'); + this.restartController.flushDeferredRestart( + 'restart:finally', + { + state: this.status.state, + startLock: this.startLock, + shouldReconnect: this.shouldReconnect, + }, + () => { + void this.restart().catch((error) => { + logger.warn('Deferred Gateway restart failed:', error); + }); + }, + ); } } @@ -657,16 +382,11 @@ export class GatewayManager extends EventEmitter { * of each other during setup. */ debouncedRestart(delayMs = 2000): void { - if (this.restartDebounceTimer) { - clearTimeout(this.restartDebounceTimer); - } - logger.debug(`Gateway restart debounced (will fire in ${delayMs}ms)`); - this.restartDebounceTimer = setTimeout(() => { - this.restartDebounceTimer = null; + this.restartController.debouncedRestart(delayMs, () => { void this.restart().catch((err) => { logger.warn('Debounced Gateway restart failed:', err); }); - }, delayMs); + }); } /** @@ -674,8 +394,14 @@ export class GatewayManager extends EventEmitter { * Falls back to restart on unsupported platforms or signaling failures. */ async reload(): Promise { - if (this.isRestartDeferred()) { - this.markDeferredRestart('reload'); + if (this.restartController.isRestartDeferred({ + state: this.status.state, + startLock: this.startLock, + })) { + this.restartController.markDeferredRestart('reload', { + state: this.status.state, + startLock: this.startLock, + }); return; } @@ -742,18 +468,8 @@ export class GatewayManager extends EventEmitter { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } - if (this.pingInterval) { - clearInterval(this.pingInterval); - this.pingInterval = null; - } - if (this.healthCheckInterval) { - clearInterval(this.healthCheckInterval); - this.healthCheckInterval = null; - } - if (this.restartDebounceTimer) { - clearTimeout(this.restartDebounceTimer); - this.restartDebounceTimer = null; - } + this.connectionMonitor.clear(); + this.restartController.clearDebounceTimer(); if (this.reloadDebounceTimer) { clearTimeout(this.reloadDebounceTimer); this.reloadDebounceTimer = null; @@ -775,8 +491,7 @@ export class GatewayManager extends EventEmitter { // Set timeout for request const timeout = setTimeout(() => { - this.pendingRequests.delete(id); - reject(new Error(`RPC timeout: ${method}`)); + rejectPendingGatewayRequest(this.pendingRequests, id, new Error(`RPC timeout: ${method}`)); }, timeoutMs); // Store pending request @@ -797,9 +512,7 @@ export class GatewayManager extends EventEmitter { try { this.ws.send(JSON.stringify(request)); } catch (error) { - this.pendingRequests.delete(id); - clearTimeout(timeout); - reject(new Error(`Failed to send RPC request: ${error}`)); + rejectPendingGatewayRequest(this.pendingRequests, id, new Error(`Failed to send RPC request: ${error}`)); } }); } @@ -808,25 +521,16 @@ export class GatewayManager extends EventEmitter { * Start health check monitoring */ private startHealthCheck(): void { - 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) { - logger.warn(`Gateway health check failed: ${health.error ?? 'unknown'}`); - this.emit('error', new Error(health.error || 'Health check failed')); - } - } catch (error) { - logger.error('Gateway health check error:', error); - } - }, 30000); // Check every 30 seconds + this.connectionMonitor.startHealthCheck({ + shouldCheck: () => this.status.state === 'running', + checkHealth: () => this.checkHealth(), + onUnhealthy: (errorMessage) => { + this.emit('error', new Error(errorMessage)); + }, + onError: () => { + // The monitor already logged the error; nothing else to do here. + }, + }); } /** @@ -847,507 +551,38 @@ export class GatewayManager extends EventEmitter { } } - /** - * Unload the system-managed openclaw gateway launchctl service if it is - * loaded. Without this, killing the process only causes launchctl to - * respawn it, leading to an infinite reconnect loop. - */ - private async unloadLaunchctlService(): Promise { - if (process.platform !== 'darwin') return; - - try { - const uid = process.getuid?.(); - if (uid === undefined) return; - - const LAUNCHD_LABEL = 'ai.openclaw.gateway'; - const serviceTarget = `gui/${uid}/${LAUNCHD_LABEL}`; - - const loaded = await new Promise((resolve) => { - import('child_process').then(cp => { - cp.exec(`launchctl print ${serviceTarget}`, { timeout: 5000 }, (err) => { - resolve(!err); - }); - }).catch(() => resolve(false)); - }); - - if (!loaded) return; - - logger.info(`Unloading launchctl service ${serviceTarget} to prevent auto-respawn`); - await new Promise((resolve) => { - import('child_process').then(cp => { - cp.exec(`launchctl bootout ${serviceTarget}`, { timeout: 10000 }, (err) => { - if (err) { - logger.warn(`Failed to bootout launchctl service: ${err.message}`); - } else { - logger.info('Successfully unloaded launchctl gateway service'); - } - resolve(); - }); - }).catch(() => resolve()); - }); - - await new Promise(r => setTimeout(r, 2000)); - - // Remove the plist so the service won't reload on next login. - try { - const { homedir } = await import('os'); - const plistPath = path.join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`); - const { access, unlink } = await import('fs/promises'); - await access(plistPath); - await unlink(plistPath); - logger.info(`Removed legacy launchd plist to prevent reload on next login: ${plistPath}`); - } catch { - // File doesn't exist or can't be removed -- not fatal - } - } catch (err) { - logger.warn('Error while unloading launchctl gateway service:', err); - } - } - - /** - * Find existing Gateway process by attempting a WebSocket connection - */ - 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. - // We use native commands (netstat on Windows) to avoid triggering AV blocks - // that flag "powershell -WindowStyle Hidden" as malware behavior. - // windowsHide: true in cp.exec natively prevents the black command window. - const cmd = process.platform === 'win32' - ? `netstat -ano | findstr :${port}` - : `lsof -i :${port} -sTCP:LISTEN -t`; - - const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => { - import('child_process').then(cp => { - cp.exec(cmd, { timeout: 5000, windowsHide: true }, (err, stdout) => { - if (err) resolve({ stdout: '' }); - else resolve({ stdout }); - }); - }).catch(reject); - }); - - if (stdout.trim()) { - // Parse netstat or lsof output to extract PIDs - let pids: string[] = []; - if (process.platform === 'win32') { - // netstat -ano output format: - // TCP 127.0.0.1:3000 0.0.0.0:0 LISTENING 12345 - const lines = stdout.trim().split(/\r?\n/); - for (const line of lines) { - const parts = line.trim().split(/\s+/); - if (parts.length >= 5 && parts[3] === 'LISTENING') { - pids.push(parts[4]); - } - } - } else { - pids = stdout.trim().split(/\r?\n/).map(s => s.trim()).filter(Boolean); - } - // Remove duplicate PIDs - pids = [...new Set(pids)]; - - 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...`); - - // 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') { - // Use taskkill with windowsHide: true. This natively hides the console - // flash without needing PowerShell, avoiding AV alerts. - import('child_process').then(cp => { - cp.exec( - `taskkill /F /PID ${pid} /T`, - { timeout: 5000, windowsHide: true }, - () => { } - ); - }).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; - } - } - } - } catch (err) { - logger.warn('Error checking for existing process on port:', err); - } - - // Try a quick WebSocket connection to check if gateway is listening - return await new Promise<{ port: number, externalToken?: string } | null>((resolve) => { - const testWs = new WebSocket(`ws://localhost:${port}/ws`); - const timeout = setTimeout(() => { - testWs.close(); - resolve(null); - }, 2000); - - testWs.on('open', () => { - clearTimeout(timeout); - testWs.close(); - resolve({ port }); - }); - - testWs.on('error', () => { - clearTimeout(timeout); - resolve(null); - }); - }); - } catch { - // Gateway not running - } - - return null; - } - - /** - * Attempt to repair invalid OpenClaw config using the built-in doctor command. - * Returns true when doctor exits successfully. - */ - private async runOpenClawDoctorRepair(): Promise { - const openclawDir = getOpenClawDir(); - const entryScript = getOpenClawEntryPath(); - if (!existsSync(entryScript)) { - logger.error(`Cannot run OpenClaw doctor repair: entry script not found at ${entryScript}`); - return false; - } - - const platform = process.platform; - const arch = process.arch; - const target = `${platform}-${arch}`; - const binPath = app.isPackaged - ? path.join(process.resourcesPath, 'bin') - : path.join(process.cwd(), 'resources', 'bin', target); - const binPathExists = existsSync(binPath); - const finalPath = binPathExists - ? `${binPath}${path.delimiter}${process.env.PATH || ''}` - : process.env.PATH || ''; - - const uvEnv = await getUvMirrorEnv(); - const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive']; - logger.info( - `Running OpenClaw doctor repair (entry="${entryScript}", args="${doctorArgs.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})` - ); - - return new Promise((resolve) => { - const forkEnv: Record = { - ...process.env, - PATH: finalPath, - ...uvEnv, - OPENCLAW_NO_RESPAWN: '1', - }; - - const child = utilityProcess.fork(entryScript, doctorArgs, { - cwd: openclawDir, - stdio: 'pipe', - env: forkEnv as NodeJS.ProcessEnv, - }); - - let settled = false; - const finish = (ok: boolean) => { - if (settled) return; - settled = true; - resolve(ok); - }; - - const timeout = setTimeout(() => { - logger.error('OpenClaw doctor repair timed out after 120000ms'); - try { - child.kill(); - } catch { - // ignore - } - finish(false); - }, 120000); - - child.on('error', (err) => { - clearTimeout(timeout); - logger.error('Failed to spawn OpenClaw doctor repair process:', err); - finish(false); - }); - - child.stdout?.on('data', (data) => { - const raw = data.toString(); - for (const line of raw.split(/\r?\n/)) { - const normalized = line.trim(); - if (!normalized) continue; - logger.debug(`[Gateway doctor stdout] ${normalized}`); - } - }); - - child.stderr?.on('data', (data) => { - const raw = data.toString(); - for (const line of raw.split(/\r?\n/)) { - const normalized = line.trim(); - if (!normalized) continue; - logger.warn(`[Gateway doctor stderr] ${normalized}`); - } - }); - - child.on('exit', (code: number) => { - clearTimeout(timeout); - if (code === 0) { - logger.info('OpenClaw doctor repair completed successfully'); - finish(true); - return; - } - logger.warn(`OpenClaw doctor repair exited (code=${code})`); - finish(false); - }); - }); - } - /** * Start Gateway process * Uses OpenClaw npm package from node_modules (dev) or resources (production) */ - /** - * Wait until the gateway port is no longer held by the OS. - * On Windows, TCP TIME_WAIT can keep a port occupied for up to 2 minutes - * after the owning process exits, causing the new Gateway to hang on bind. - */ - private async waitForPortFree(port: number, timeoutMs = 30000): Promise { - const net = await import('net'); - const start = Date.now(); - const pollInterval = 500; - let logged = false; - - while (Date.now() - start < timeoutMs) { - const available = await new Promise((resolve) => { - const server = net.createServer(); - server.once('error', () => resolve(false)); - server.once('listening', () => { - server.close(() => resolve(true)); - }); - server.listen(port, '127.0.0.1'); - }); - - if (available) { - const elapsed = Date.now() - start; - if (elapsed > pollInterval) { - logger.info(`Port ${port} became available after ${elapsed}ms`); - } - return; - } - - if (!logged) { - logger.info(`Waiting for port ${port} to become available (Windows TCP TIME_WAIT)...`); - logged = true; - } - await new Promise(r => setTimeout(r, pollInterval)); - } - - logger.warn(`Port ${port} still occupied after ${timeoutMs}ms, proceeding anyway`); - } - private async startProcess(): Promise { - // Ensure no system-managed gateway service will compete with our process. - await this.unloadLaunchctlService(); + const launchContext = await prepareGatewayLaunchContext(this.status.port); + await unloadLaunchctlGatewayService(); + this.processExitCode = null; - 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 appSettings = await getAllSettings(); - const gatewayToken = appSettings.gatewayToken; - await syncProxyConfigToOpenClaw(appSettings); - - // Strip stale/invalid keys from openclaw.json that would cause the - // Gateway's strict config validation to reject the file on startup - // (e.g. `skills.enabled` left by an older version). - // This is a fast file-based pre-check; the reactive auto-repair - // mechanism (runOpenClawDoctorRepair) handles any remaining issues. - try { - await sanitizeOpenClawConfig(); - } catch (err) { - logger.warn('Failed to sanitize openclaw.json:', err); - } - - // Write our token into openclaw.json before starting the process. - // Without --dev the gateway authenticates using the token in - // openclaw.json; if that file has a stale token (e.g. left by the - // system-managed launchctl service) the WebSocket handshake will fail - // with "token mismatch" even though we pass --token on the CLI. - try { - await syncGatewayTokenToConfig(gatewayToken); - } catch (err) { - logger.warn('Failed to sync gateway token to openclaw.json:', err); - } - - try { - await syncBrowserConfigToOpenClaw(); - } catch (err) { - logger.warn('Failed to sync browser config to openclaw.json:', err); - } - - // utilityProcess.fork() works for both dev and packaged — no ELECTRON_RUN_AS_NODE needed. - if (!existsSync(entryScript)) { - const errMsg = `OpenClaw entry script not found at: ${entryScript}`; - logger.error(errMsg); - throw new Error(errMsg); - } - - const gatewayArgs = ['gateway', '--port', String(this.status.port), '--token', gatewayToken, '--allow-unconfigured']; - const mode = app.isPackaged ? 'packaged' : 'dev'; - - // Resolve bundled bin path for uv - const platform = process.platform; - const arch = process.arch; - const target = `${platform}-${arch}`; - - const binPath = app.isPackaged - ? path.join(process.resourcesPath, 'bin') - : path.join(process.cwd(), 'resources', 'bin', target); - - const binPathExists = existsSync(binPath); - 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(); - let loadedProviderKeyCount = 0; - - // Prefer the selected default provider key when provider IDs are instance-based. - try { - const defaultProviderId = await getDefaultProvider(); - if (defaultProviderId) { - const defaultProvider = await getProvider(defaultProviderId); - const defaultProviderType = defaultProvider?.type; - const defaultProviderKey = await getApiKey(defaultProviderId); - if (defaultProviderType && defaultProviderKey) { - const envVars = getProviderEnvVars(defaultProviderType); - if (envVars.length > 0) { - for (const envVar of envVars) { - providerEnv[envVar] = defaultProviderKey; - } - if (defaultProviderType === 'moonshot') { - injectMoonshotWebSearchEnv(providerEnv, defaultProviderKey); - } - loadedProviderKeyCount++; - } + const { child, lastSpawnSummary } = await launchGatewayProcess({ + port: this.status.port, + launchContext, + sanitizeSpawnArgs: (args) => this.sanitizeSpawnArgs(args), + getCurrentState: () => this.status.state, + getShouldReconnect: () => this.shouldReconnect, + onStderrLine: (line) => { + recordGatewayStartupStderrLine(this.recentStartupStderrLines, line); + const classified = classifyGatewayStderrMessage(line); + if (classified.level === 'drop') return; + if (classified.level === 'debug') { + logger.debug(`[Gateway stderr] ${classified.normalized}`); + return; } - } - } catch (err) { - logger.warn('Failed to load default provider key for environment injection:', err); - } - - for (const providerType of providerTypes) { - try { - const key = await getApiKey(providerType); - if (key) { - const envVars = getProviderEnvVars(providerType); - if (envVars.length > 0) { - for (const envVar of envVars) { - providerEnv[envVar] = key; - } - if (providerType === 'moonshot') { - injectMoonshotWebSearchEnv(providerEnv, key); - } - loadedProviderKeyCount++; - } - } - } catch (err) { - logger.warn(`Failed to load API key for ${providerType}:`, err); - } - } - - const uvEnv = await getUvMirrorEnv(); - const proxyEnv = buildProxyEnv(appSettings); - const resolvedProxy = resolveProxySettings(appSettings); - logger.info( - `Starting Gateway process (mode=${mode}, port=${this.status.port}, entry="${entryScript}", args="${this.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, proxy=${appSettings.proxyEnabled ? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}` : 'disabled'})` - ); - this.lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${this.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`; - - return new Promise((resolve, reject) => { - // Reset exit tracking for this new process instance. - this.processExitCode = null; - const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env; - const forkEnv: Record = { - ...baseEnv, - PATH: finalPath, - ...providerEnv, - ...uvEnv, - ...proxyEnv, - OPENCLAW_GATEWAY_TOKEN: gatewayToken, - OPENCLAW_SKIP_CHANNELS: '', - CLAWDBOT_SKIP_CHANNELS: '', - // Prevent OpenClaw from respawning itself inside the utility process - OPENCLAW_NO_RESPAWN: '1', - }; - - // Inject fetch preload so OpenRouter requests carry ClawX headers. - // The preload patches globalThis.fetch before any module loads. - // NODE_OPTIONS --require is blocked by Electron in packaged apps, so skip - // this injection when packaged to avoid the "NODE_OPTIONs not supported" - // errors being printed to the gateway's stderr on every startup. - if (!app.isPackaged) { - try { - const preloadPath = ensureGatewayFetchPreload(); - if (existsSync(preloadPath)) { - forkEnv['NODE_OPTIONS'] = appendNodeRequireToNodeOptions( - forkEnv['NODE_OPTIONS'], - preloadPath, - ); - } - } catch (err) { - logger.warn('Failed to set up OpenRouter headers preload:', err); - } - } - - // utilityProcess.fork() runs the .mjs entry directly without spawning a - // shell or visible console window. Works identically in dev and packaged. - this.process = utilityProcess.fork(entryScript, gatewayArgs, { - cwd: openclawDir, - stdio: 'pipe', - env: forkEnv as NodeJS.ProcessEnv, - serviceName: 'OpenClaw Gateway', - }); - 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: number) => { + logger.warn(`[Gateway stderr] ${classified.normalized}`); + }, + onSpawn: (pid) => { + this.setStatus({ pid }); + }, + onExit: (exitedChild, code) => { this.processExitCode = code; - const expectedExit = !this.shouldReconnect || this.status.state === 'stopped'; - const level = expectedExit ? logger.info : logger.warn; - level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`); this.ownsProcess = false; - if (this.process === child) { + if (this.process === exitedChild) { this.process = null; } this.emit('exit', code); @@ -1356,307 +591,48 @@ export class GatewayManager extends EventEmitter { this.setStatus({ state: 'stopped' }); this.scheduleReconnect(); } - }); - - // UtilityProcess doesn't emit 'close'; stdout/stderr end naturally on exit. - - // Log stderr - child.stderr?.on('data', (data) => { - const raw = data.toString(); - for (const line of raw.split(/\r?\n/)) { - this.recordStartupStderrLine(line); - const classified = this.classifyStderrMessage(line); - if (classified.level === 'drop') continue; - if (classified.level === 'debug') { - logger.debug(`[Gateway stderr] ${classified.normalized}`); - continue; - } - logger.warn(`[Gateway stderr] ${classified.normalized}`); + }, + onError: () => { + this.ownsProcess = false; + if (this.process === child) { + this.process = null; } - }); - - // PID is only available after the child process has fully spawned. - // utilityProcess.fork() is asynchronous — child.pid is undefined if read - // synchronously right after fork(). Use the 'spawned' event instead. - child.on('spawn', () => { - logger.info(`Gateway process started (pid=${child.pid})`); - this.setStatus({ pid: child.pid }); - }); - - resolve(); + }, }); - } - /** - * Wait for Gateway to be ready by checking if the port is accepting connections - */ - private async waitForReady(retries = 2400, interval = 250): Promise { - const child = this.process; - for (let i = 0; i < retries; i++) { - // Early exit if the gateway process has already exited. - // UtilityProcess has no synchronous exitCode/signalCode — use our tracked flag. - if (child && this.processExitCode !== null) { - const code = this.processExitCode; - logger.error(`Gateway process exited before ready (code=${code})`); - throw new Error(`Gateway process exited before becoming ready (code=${code})`); - } - - try { - const ready = await new Promise((resolve) => { - const testWs = new WebSocket(`ws://localhost:${this.status.port}/ws`); - const timeout = setTimeout(() => { - 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; - } - } 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})`); + this.process = child; + this.ownsProcess = true; + this.lastSpawnSummary = lastSpawnSummary; } /** * 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; - let handshakeTimeout: NodeJS.Timeout | null = null; - let settled = false; - - let challengeTimer: NodeJS.Timeout | null = null; - - const cleanupHandshakeRequest = () => { - if (challengeTimer) { - clearTimeout(challengeTimer); - challengeTimer = null; - } - if (handshakeTimeout) { - clearTimeout(handshakeTimeout); - handshakeTimeout = null; - } - if (connectId && this.pendingRequests.has(connectId)) { - const request = this.pendingRequests.get(connectId); - if (request) { - clearTimeout(request.timeout); - } - this.pendingRequests.delete(connectId); - } - }; - - const resolveOnce = () => { - if (settled) return; - settled = true; - cleanupHandshakeRequest(); - resolve(); - }; - - const rejectOnce = (error: unknown) => { - if (settled) return; - settled = true; - cleanupHandshakeRequest(); - 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'); - - const currentToken = await getSetting('gatewayToken'); - - connectId = `connect-${Date.now()}`; - const role = 'operator'; - const scopes = ['operator.admin']; - const signedAtMs = Date.now(); - const clientId = 'gateway-client'; - const clientMode = 'ui'; - - const device = (() => { - if (!this.deviceIdentity) return undefined; - - const payload = buildDeviceAuthPayload({ - deviceId: this.deviceIdentity.deviceId, - clientId, - clientMode, - role, - scopes, - signedAtMs, - token: currentToken ?? null, - nonce: challengeNonce, - }); - const signature = signDevicePayload(this.deviceIdentity.privateKeyPem, payload); - return { - id: this.deviceIdentity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(this.deviceIdentity.publicKeyPem), - signature, - signedAt: signedAtMs, - nonce: challengeNonce, - }; - })(); - - const connectFrame = { - type: 'req', - id: connectId, - method: 'connect', - params: { - minProtocol: 3, - maxProtocol: 3, - client: { - id: clientId, - displayName: 'ClawX', - version: '0.1.0', - platform: process.platform, - mode: clientMode, - }, - auth: { - token: currentToken, - }, - caps: [], - role, - scopes, - device, - }, - }; - - this.ws?.send(JSON.stringify(connectFrame)); - - const requestTimeout = setTimeout(() => { - if (!handshakeComplete) { - logger.error('Gateway connect handshake timed out'); - this.ws?.close(); - rejectOnce(new Error('Connect handshake timeout')); - } - }, 10000); - handshakeTimeout = requestTimeout; - - this.pendingRequests.set(connectId, { - resolve: (_result) => { - handshakeComplete = true; - logger.debug('Gateway connect handshake completed'); - this.setStatus({ - state: 'running', - port, - connectedAt: Date.now(), - }); - this.startPing(); - resolveOnce(); - }, - reject: (error) => { - logger.error('Gateway connect handshake failed:', error); - rejectOnce(error); - }, - timeout: requestTimeout, + this.ws = await connectGatewaySocket({ + port, + deviceIdentity: this.deviceIdentity, + platform: process.platform, + pendingRequests: this.pendingRequests, + getToken: async () => await import('../utils/store').then(({ getSetting }) => getSetting('gatewayToken')), + onHandshakeComplete: (ws) => { + this.ws = ws; + this.setStatus({ + state: 'running', + port, + connectedAt: Date.now(), }); - }; - - // Timeout for receiving the initial connect.challenge from the server. - // Without this, if the server never sends the challenge (e.g. orphaned - // process from a different version), the connect() promise hangs forever. - challengeTimer = setTimeout(() => { - if (!challengeReceived && !settled) { - logger.error('Gateway connect.challenge not received within timeout'); - this.ws?.close(); - rejectOnce(new Error('Timed out waiting for connect.challenge from Gateway')); - } - }, 10000); - - this.ws.on('open', () => { - logger.debug('Gateway WebSocket opened, waiting for connect.challenge...'); - }); - - let challengeReceived = false; - - this.ws.on('message', (data) => { - try { - const message = JSON.parse(data.toString()); - - // Intercept the connect.challenge event before the general handler - if ( - !challengeReceived && - typeof message === 'object' && message !== null && - message.type === 'event' && message.event === 'connect.challenge' - ) { - challengeReceived = true; - if (challengeTimer) { - clearTimeout(challengeTimer); - challengeTimer = null; - } - const nonce = message.payload?.nonce as string | undefined; - if (!nonce) { - rejectOnce(new Error('Gateway connect.challenge missing nonce')); - return; - } - logger.debug('Received connect.challenge, sending handshake'); - sendConnectHandshake(nonce); - return; - } - - this.handleMessage(message); - } catch (error) { - 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; - } - cleanupHandshakeRequest(); + this.startPing(); + }, + onMessage: (message) => { + this.handleMessage(message); + }, + onCloseAfterHandshake: () => { if (this.status.state === 'running') { this.setStatus({ state: 'stopped' }); this.scheduleReconnect(); } - }); - - this.ws.on('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); - } - }); + }, }); } @@ -1673,148 +649,80 @@ export class GatewayManager extends EventEmitter { // 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'; - request.reject(new Error(errorMsg)); - } else { - request.resolve(msg.payload ?? msg); + 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'; + if (rejectPendingGatewayRequest(this.pendingRequests, msg.id, new Error(errorMsg))) { + return; } + } else if (resolvePendingGatewayRequest(this.pendingRequests, msg.id, msg.payload ?? msg)) { return; } } // Handle OpenClaw protocol event format: { type: "event", event: "...", payload: {...} } if (msg.type === 'event' && typeof msg.event === 'string') { - this.handleProtocolEvent(msg.event, msg.payload); + dispatchProtocolEvent(this, 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' ? (message.error as { message?: string }).message || JSON.stringify(message.error) : String(message.error); - request.reject(new Error(errorMsg)); + rejectPendingGatewayRequest(this.pendingRequests, String(message.id), new Error(errorMsg)); } else { - request.resolve(message.result); + resolvePendingGatewayRequest(this.pendingRequests, String(message.id), message.result); } return; } // Check if this is a JSON-RPC notification (server-initiated event) if (isNotification(message)) { - this.handleNotification(message); + dispatchJsonRpcNotification(this, message); return; } this.emit('message', message); } - /** - * Handle OpenClaw protocol events - */ - private handleProtocolEvent(event: string, payload: unknown): void { - switch (event) { - case 'tick': - break; - case 'chat': - this.emit('chat:message', { message: payload }); - break; - case 'agent': { - // Agent events may carry chat streaming data inside payload.data, - // or be lifecycle events (phase=started/completed) with no message. - const p = payload as Record; - const data = (p.data && typeof p.data === 'object') ? p.data as Record : {}; - const chatEvent: Record = { - ...data, - runId: p.runId ?? data.runId, - sessionKey: p.sessionKey ?? data.sessionKey, - state: p.state ?? data.state, - message: p.message ?? data.message, - }; - if (chatEvent.state || chatEvent.message) { - this.emit('chat:message', { message: chatEvent }); - } - this.emit('notification', { method: event, params: payload }); - break; - } - case 'channel.status': - this.emit('channel:status', payload as { channelId: string; status: string }); - break; - default: - 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 */ private startPing(): void { - if (this.pingInterval) { - clearInterval(this.pingInterval); - } - - this.pingInterval = setInterval(() => { + this.connectionMonitor.startPing(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.ping(); } - }, 30000); + }); } /** * Schedule reconnection attempt with exponential backoff */ private scheduleReconnect(): void { - if (!this.shouldReconnect) { - logger.debug('Gateway reconnect skipped (auto-reconnect disabled)'); + const decision = getReconnectScheduleDecision({ + shouldReconnect: this.shouldReconnect, + hasReconnectTimer: this.reconnectTimer !== null, + reconnectAttempts: this.reconnectAttempts, + maxAttempts: this.reconnectConfig.maxAttempts, + baseDelay: this.reconnectConfig.baseDelay, + maxDelay: this.reconnectConfig.maxDelay, + }); + + if (decision.action === 'skip') { + logger.debug(`Gateway reconnect skipped (${decision.reason})`); return; } - if (this.reconnectTimer) { + if (decision.action === 'already-scheduled') { return; } - if (this.reconnectAttempts >= this.reconnectConfig.maxAttempts) { - logger.error(`Gateway reconnect failed: max attempts reached (${this.reconnectConfig.maxAttempts})`); + if (decision.action === 'fail') { + logger.error(`Gateway reconnect failed: max attempts reached (${decision.maxAttempts})`); this.setStatus({ state: 'error', error: 'Failed to reconnect after maximum attempts', @@ -1823,26 +731,21 @@ export class GatewayManager extends EventEmitter { 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`); + const { delay, nextAttempt, maxAttempts } = decision; + this.reconnectAttempts = nextAttempt; + logger.warn(`Scheduling Gateway reconnect attempt ${nextAttempt}/${maxAttempts} in ${delay}ms`); this.setStatus({ state: 'reconnecting', reconnectAttempts: this.reconnectAttempts }); - const scheduledEpoch = this.lifecycleEpoch; + const scheduledEpoch = this.lifecycleController.getCurrentEpoch(); this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = null; const skipReason = getReconnectSkipReason({ scheduledEpoch, - currentEpoch: this.lifecycleEpoch, + currentEpoch: this.lifecycleController.getCurrentEpoch(), shouldReconnect: this.shouldReconnect, }); if (skipReason) { @@ -1865,20 +768,6 @@ export class GatewayManager extends EventEmitter { * 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}`); - this.flushDeferredRestart(`status:${previousState}->${this.status.state}`); - } + this.stateController.setStatus(update); } -} +} \ No newline at end of file diff --git a/electron/gateway/process-launcher.ts b/electron/gateway/process-launcher.ts new file mode 100644 index 000000000..b9c5e8e7d --- /dev/null +++ b/electron/gateway/process-launcher.ts @@ -0,0 +1,180 @@ +import { app, utilityProcess } from 'electron'; +import { existsSync, writeFileSync } from 'fs'; +import path from 'path'; +import type { GatewayLaunchContext } from './config-sync'; +import type { GatewayLifecycleState } from './process-policy'; +import { logger } from '../utils/logger'; +import { appendNodeRequireToNodeOptions } from '../utils/paths'; + +const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict'; +(function () { + var _f = globalThis.fetch; + if (typeof _f !== 'function') return; + if (globalThis.__clawxFetchPatched) return; + globalThis.__clawxFetchPatched = true; + + globalThis.fetch = function clawxFetch(input, init) { + var url = + typeof input === 'string' ? input + : input && typeof input === 'object' && typeof input.url === 'string' + ? input.url : ''; + + if (url.indexOf('openrouter.ai') !== -1) { + init = init ? Object.assign({}, init) : {}; + var prev = init.headers; + var flat = {}; + if (prev && typeof prev.forEach === 'function') { + prev.forEach(function (v, k) { flat[k] = v; }); + } else if (prev && typeof prev === 'object') { + Object.assign(flat, prev); + } + delete flat['http-referer']; + delete flat['HTTP-Referer']; + delete flat['x-title']; + delete flat['X-Title']; + flat['HTTP-Referer'] = 'https://claw-x.com'; + flat['X-Title'] = 'ClawX'; + init.headers = flat; + } + return _f.call(globalThis, input, init); + }; + + if (process.platform === 'win32') { + try { + var cp = require('child_process'); + if (!cp.__clawxPatched) { + cp.__clawxPatched = true; + ['spawn', 'exec', 'execFile', 'fork', 'spawnSync', 'execSync', 'execFileSync'].forEach(function(method) { + var original = cp[method]; + if (typeof original !== 'function') return; + cp[method] = function() { + var args = Array.prototype.slice.call(arguments); + var optIdx = -1; + for (var i = 1; i < args.length; i++) { + var a = args[i]; + if (a && typeof a === 'object' && !Array.isArray(a)) { + optIdx = i; + break; + } + } + if (optIdx >= 0) { + args[optIdx].windowsHide = true; + } else { + var opts = { windowsHide: true }; + if (typeof args[args.length - 1] === 'function') { + args.splice(args.length - 1, 0, opts); + } else { + args.push(opts); + } + } + return original.apply(this, args); + }; + }); + } + } catch (e) { + // ignore + } + } +})(); +`; + +function ensureGatewayFetchPreload(): string { + const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs'); + try { + writeFileSync(dest, GATEWAY_FETCH_PRELOAD_SOURCE, 'utf-8'); + } catch { + // best-effort + } + return dest; +} + +export async function launchGatewayProcess(options: { + port: number; + launchContext: GatewayLaunchContext; + sanitizeSpawnArgs: (args: string[]) => string[]; + getCurrentState: () => GatewayLifecycleState; + getShouldReconnect: () => boolean; + onStderrLine: (line: string) => void; + onSpawn: (pid: number | undefined) => void; + onExit: (child: Electron.UtilityProcess, code: number | null) => void; + onError: (error: Error) => void; +}): Promise<{ child: Electron.UtilityProcess; lastSpawnSummary: string }> { + const { + openclawDir, + entryScript, + gatewayArgs, + forkEnv, + mode, + binPathExists, + loadedProviderKeyCount, + proxySummary, + channelStartupSummary, + } = options.launchContext; + + logger.info( + `Starting Gateway process (mode=${mode}, port=${options.port}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, channels=${channelStartupSummary}, proxy=${proxySummary})`, + ); + const lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`; + + const runtimeEnv = { ...forkEnv }; + if (!app.isPackaged) { + try { + const preloadPath = ensureGatewayFetchPreload(); + if (existsSync(preloadPath)) { + runtimeEnv.NODE_OPTIONS = appendNodeRequireToNodeOptions( + runtimeEnv.NODE_OPTIONS, + preloadPath, + ); + } + } catch (err) { + logger.warn('Failed to set up OpenRouter headers preload:', err); + } + } + + return await new Promise<{ child: Electron.UtilityProcess; lastSpawnSummary: string }>((resolve, reject) => { + const child = utilityProcess.fork(entryScript, gatewayArgs, { + cwd: openclawDir, + stdio: 'pipe', + env: runtimeEnv as NodeJS.ProcessEnv, + serviceName: 'OpenClaw Gateway', + }); + + let settled = false; + const resolveOnce = () => { + if (settled) return; + settled = true; + resolve({ child, lastSpawnSummary }); + }; + const rejectOnce = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; + + child.on('error', (error) => { + logger.error('Gateway process spawn error:', error); + options.onError(error); + rejectOnce(error); + }); + + child.on('exit', (code: number) => { + const expectedExit = !options.getShouldReconnect() || options.getCurrentState() === 'stopped'; + const level = expectedExit ? logger.info : logger.warn; + level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`); + options.onExit(child, code); + }); + + child.stderr?.on('data', (data) => { + const raw = data.toString(); + for (const line of raw.split(/\r?\n/)) { + options.onStderrLine(line); + } + }); + + child.on('spawn', () => { + logger.info(`Gateway process started (pid=${child.pid})`); + options.onSpawn(child.pid); + resolveOnce(); + }); + }); +} diff --git a/electron/gateway/process-policy.ts b/electron/gateway/process-policy.ts index ece3b6596..607e13f7d 100644 --- a/electron/gateway/process-policy.ts +++ b/electron/gateway/process-policy.ts @@ -1,3 +1,15 @@ +export interface ReconnectConfig { + maxAttempts: number; + baseDelay: number; + maxDelay: number; +} + +export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = { + maxAttempts: 10, + baseDelay: 1000, + maxDelay: 30000, +}; + export function nextLifecycleEpoch(currentEpoch: number): number { return currentEpoch + 1; } @@ -22,6 +34,53 @@ export function getReconnectSkipReason(context: ReconnectAttemptContext): string return null; } +export interface ReconnectScheduleContext { + shouldReconnect: boolean; + hasReconnectTimer: boolean; + reconnectAttempts: number; + maxAttempts: number; + baseDelay: number; + maxDelay: number; +} + +export type ReconnectScheduleDecision = + | { action: 'skip'; reason: string } + | { action: 'already-scheduled' } + | { action: 'fail'; attempts: number; maxAttempts: number } + | { action: 'schedule'; nextAttempt: number; maxAttempts: number; delay: number }; + +export function getReconnectScheduleDecision( + context: ReconnectScheduleContext, +): ReconnectScheduleDecision { + if (!context.shouldReconnect) { + return { action: 'skip', reason: 'auto-reconnect disabled' }; + } + + if (context.hasReconnectTimer) { + return { action: 'already-scheduled' }; + } + + if (context.reconnectAttempts >= context.maxAttempts) { + return { + action: 'fail', + attempts: context.reconnectAttempts, + maxAttempts: context.maxAttempts, + }; + } + + const delay = Math.min( + context.baseDelay * Math.pow(2, context.reconnectAttempts), + context.maxDelay, + ); + + return { + action: 'schedule', + nextAttempt: context.reconnectAttempts + 1, + maxAttempts: context.maxAttempts, + delay, + }; +} + export type GatewayLifecycleState = 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting'; export interface RestartDeferralContext { diff --git a/electron/gateway/request-store.ts b/electron/gateway/request-store.ts new file mode 100644 index 000000000..bf96f9ebf --- /dev/null +++ b/electron/gateway/request-store.ts @@ -0,0 +1,42 @@ +export interface PendingGatewayRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; +} + +export function clearPendingGatewayRequests( + pendingRequests: Map, + error: Error, +): void { + for (const [, request] of pendingRequests) { + clearTimeout(request.timeout); + request.reject(error); + } + pendingRequests.clear(); +} + +export function resolvePendingGatewayRequest( + pendingRequests: Map, + id: string, + value: unknown, +): boolean { + const request = pendingRequests.get(id); + if (!request) return false; + clearTimeout(request.timeout); + pendingRequests.delete(id); + request.resolve(value); + return true; +} + +export function rejectPendingGatewayRequest( + pendingRequests: Map, + id: string, + error: Error, +): boolean { + const request = pendingRequests.get(id); + if (!request) return false; + clearTimeout(request.timeout); + pendingRequests.delete(id); + request.reject(error); + return true; +} diff --git a/electron/gateway/restart-controller.ts b/electron/gateway/restart-controller.ts new file mode 100644 index 000000000..9db23df6f --- /dev/null +++ b/electron/gateway/restart-controller.ts @@ -0,0 +1,91 @@ +import { logger } from '../utils/logger'; +import { + getDeferredRestartAction, + shouldDeferRestart, + type GatewayLifecycleState, +} from './process-policy'; + +type RestartDeferralState = { + state: GatewayLifecycleState; + startLock: boolean; +}; + +type DeferredRestartContext = RestartDeferralState & { + shouldReconnect: boolean; +}; + +export class GatewayRestartController { + private deferredRestartPending = false; + private restartDebounceTimer: NodeJS.Timeout | null = null; + + isRestartDeferred(context: RestartDeferralState): boolean { + return shouldDeferRestart(context); + } + + markDeferredRestart(reason: string, context: RestartDeferralState): void { + if (!this.deferredRestartPending) { + logger.info( + `Deferring Gateway restart (${reason}) until startup/reconnect settles (state=${context.state}, startLock=${context.startLock})`, + ); + } else { + logger.debug( + `Gateway restart already deferred; keeping pending request (${reason}, state=${context.state}, startLock=${context.startLock})`, + ); + } + this.deferredRestartPending = true; + } + + flushDeferredRestart( + trigger: string, + context: DeferredRestartContext, + executeRestart: () => void, + ): void { + const action = getDeferredRestartAction({ + hasPendingRestart: this.deferredRestartPending, + state: context.state, + startLock: context.startLock, + shouldReconnect: context.shouldReconnect, + }); + + if (action === 'none') return; + if (action === 'wait') { + logger.debug( + `Deferred Gateway restart still waiting (${trigger}, state=${context.state}, startLock=${context.startLock})`, + ); + return; + } + + this.deferredRestartPending = false; + if (action === 'drop') { + logger.info( + `Dropping deferred Gateway restart (${trigger}) because lifecycle already recovered (state=${context.state}, shouldReconnect=${context.shouldReconnect})`, + ); + return; + } + + logger.info(`Executing deferred Gateway restart now (${trigger})`); + executeRestart(); + } + + debouncedRestart(delayMs: number, executeRestart: () => void): void { + if (this.restartDebounceTimer) { + clearTimeout(this.restartDebounceTimer); + } + logger.debug(`Gateway restart debounced (will fire in ${delayMs}ms)`); + this.restartDebounceTimer = setTimeout(() => { + this.restartDebounceTimer = null; + executeRestart(); + }, delayMs); + } + + clearDebounceTimer(): void { + if (this.restartDebounceTimer) { + clearTimeout(this.restartDebounceTimer); + this.restartDebounceTimer = null; + } + } + + resetDeferredRestart(): void { + this.deferredRestartPending = false; + } +} diff --git a/electron/gateway/startup-orchestrator.ts b/electron/gateway/startup-orchestrator.ts new file mode 100644 index 000000000..eb3066384 --- /dev/null +++ b/electron/gateway/startup-orchestrator.ts @@ -0,0 +1,106 @@ +import { logger } from '../utils/logger'; +import { LifecycleSupersededError } from './lifecycle-controller'; +import { getGatewayStartupRecoveryAction } from './startup-recovery'; + +export interface ExistingGatewayInfo { + port: number; + externalToken?: string; +} + +type StartupHooks = { + port: number; + ownedPid?: number; + shouldWaitForPortFree: boolean; + maxStartAttempts?: number; + resetStartupStderrLines: () => void; + getStartupStderrLines: () => string[]; + assertLifecycle: (phase: string) => void; + findExistingGateway: (port: number, ownedPid?: number) => Promise; + connect: (port: number, externalToken?: string) => Promise; + onConnectedToExistingGateway: () => void; + waitForPortFree: (port: number) => Promise; + startProcess: () => Promise; + waitForReady: (port: number) => Promise; + onConnectedToManagedGateway: () => void; + runDoctorRepair: () => Promise; + onDoctorRepairSuccess: () => void; + delay: (ms: number) => Promise; +}; + +export async function runGatewayStartupSequence(hooks: StartupHooks): Promise { + let configRepairAttempted = false; + let startAttempts = 0; + const maxStartAttempts = hooks.maxStartAttempts ?? 3; + + while (true) { + startAttempts++; + hooks.assertLifecycle('start'); + hooks.resetStartupStderrLines(); + + try { + logger.debug('Checking for existing Gateway...'); + const existing = await hooks.findExistingGateway(hooks.port, hooks.ownedPid); + hooks.assertLifecycle('start/find-existing'); + if (existing) { + logger.debug(`Found existing Gateway on port ${existing.port}`); + await hooks.connect(existing.port, existing.externalToken); + hooks.assertLifecycle('start/connect-existing'); + hooks.onConnectedToExistingGateway(); + return; + } + + logger.debug('No existing Gateway found, starting new process...'); + + if (hooks.shouldWaitForPortFree) { + await hooks.waitForPortFree(hooks.port); + hooks.assertLifecycle('start/wait-port'); + } + + await hooks.startProcess(); + hooks.assertLifecycle('start/start-process'); + + await hooks.waitForReady(hooks.port); + hooks.assertLifecycle('start/wait-ready'); + + await hooks.connect(hooks.port); + hooks.assertLifecycle('start/connect'); + + hooks.onConnectedToManagedGateway(); + return; + } catch (error) { + if (error instanceof LifecycleSupersededError) { + throw error; + } + + const recoveryAction = getGatewayStartupRecoveryAction({ + startupError: error, + startupStderrLines: hooks.getStartupStderrLines(), + configRepairAttempted, + attempt: startAttempts, + maxAttempts: maxStartAttempts, + }); + + if (recoveryAction === 'repair') { + configRepairAttempted = true; + logger.warn( + 'Detected invalid OpenClaw config during Gateway startup; running doctor repair before retry', + ); + const repaired = await hooks.runDoctorRepair(); + if (repaired) { + logger.info('OpenClaw doctor repair completed; retrying Gateway startup'); + hooks.onDoctorRepairSuccess(); + continue; + } + logger.error('OpenClaw doctor repair failed; not retrying Gateway startup'); + } + + if (recoveryAction === 'retry') { + logger.warn(`Transient start error: ${String(error)}. Retrying... (${startAttempts}/${maxStartAttempts})`); + await hooks.delay(1000); + continue; + } + + throw error; + } + } +} diff --git a/electron/gateway/startup-recovery.ts b/electron/gateway/startup-recovery.ts index c630a51b1..a35685ef7 100644 --- a/electron/gateway/startup-recovery.ts +++ b/electron/gateway/startup-recovery.ts @@ -12,6 +12,14 @@ const INVALID_CONFIG_PATTERNS: RegExp[] = [ /\brun:\s*openclaw doctor --fix\b/i, ]; +const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [ + /WebSocket closed before handshake/i, + /ECONNREFUSED/i, + /Gateway process exited before becoming ready/i, + /Timed out waiting for connect\.challenge/i, + /Connect handshake timeout/i, +]; + function normalizeLogLine(value: string): string { return value.trim(); } @@ -58,3 +66,34 @@ export function shouldAttemptConfigAutoRepair( return hasInvalidConfigFailureSignal(startupError, startupStderrLines); } +export function isTransientGatewayStartError(error: unknown): boolean { + const errorText = error instanceof Error + ? `${error.name}: ${error.message}` + : String(error ?? ''); + return TRANSIENT_START_ERROR_PATTERNS.some((pattern) => pattern.test(errorText)); +} + +export type GatewayStartupRecoveryAction = 'repair' | 'retry' | 'fail'; + +export function getGatewayStartupRecoveryAction(options: { + startupError: unknown; + startupStderrLines: string[]; + configRepairAttempted: boolean; + attempt: number; + maxAttempts: number; +}): GatewayStartupRecoveryAction { + if (shouldAttemptConfigAutoRepair( + options.startupError, + options.startupStderrLines, + options.configRepairAttempted, + )) { + return 'repair'; + } + + if (options.attempt < options.maxAttempts && isTransientGatewayStartError(options.startupError)) { + return 'retry'; + } + + return 'fail'; +} + diff --git a/electron/gateway/startup-stderr.ts b/electron/gateway/startup-stderr.ts new file mode 100644 index 000000000..b23f08936 --- /dev/null +++ b/electron/gateway/startup-stderr.ts @@ -0,0 +1,42 @@ +export type GatewayStderrClassification = { + level: 'drop' | 'debug' | 'warn'; + normalized: string; +}; + +const MAX_STDERR_LINES = 120; + +export function classifyGatewayStderrMessage(message: string): GatewayStderrClassification { + const msg = message.trim(); + if (!msg) { + return { level: 'drop', normalized: msg }; + } + + // Known noisy lines that are not actionable for Gateway lifecycle debugging. + if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) { + return { level: 'drop', normalized: msg }; + } + if (msg.includes('closed before connect') && msg.includes('token mismatch')) { + return { level: 'drop', normalized: msg }; + } + + // Downgrade frequent non-fatal noise. + if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg }; + if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg }; + if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg }; + + // Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless. + if (msg.includes('node: --require is not allowed in NODE_OPTIONS')) { + return { level: 'debug', normalized: msg }; + } + + return { level: 'warn', normalized: msg }; +} + +export function recordGatewayStartupStderrLine(lines: string[], line: string): void { + const normalized = line.trim(); + if (!normalized) return; + lines.push(normalized); + if (lines.length > MAX_STDERR_LINES) { + lines.splice(0, lines.length - MAX_STDERR_LINES); + } +} diff --git a/electron/gateway/state.ts b/electron/gateway/state.ts new file mode 100644 index 000000000..dc42cdce5 --- /dev/null +++ b/electron/gateway/state.ts @@ -0,0 +1,38 @@ +import { PORTS } from '../utils/config'; +import { logger } from '../utils/logger'; +import type { GatewayStatus } from './manager'; + +type GatewayStateHooks = { + emitStatus: (status: GatewayStatus) => void; + onTransition?: (previousState: GatewayStatus['state'], nextState: GatewayStatus['state']) => void; +}; + +export class GatewayStateController { + private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY }; + + constructor(private readonly hooks: GatewayStateHooks) {} + + getStatus(): GatewayStatus { + return { ...this.status }; + } + + isConnected(isSocketOpen: boolean): boolean { + return this.status.state === 'running' && isSocketOpen; + } + + setStatus(update: Partial): void { + const previousState = this.status.state; + this.status = { ...this.status, ...update }; + + if (this.status.state === 'running' && this.status.connectedAt) { + this.status.uptime = Date.now() - this.status.connectedAt; + } + + this.hooks.emitStatus(this.status); + + if (previousState !== this.status.state) { + logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`); + this.hooks.onTransition?.(previousState, this.status.state); + } + } +} diff --git a/electron/gateway/supervisor.ts b/electron/gateway/supervisor.ts new file mode 100644 index 000000000..5379b565f --- /dev/null +++ b/electron/gateway/supervisor.ts @@ -0,0 +1,348 @@ +import { app, utilityProcess } from 'electron'; +import path from 'path'; +import { existsSync } from 'fs'; +import WebSocket from 'ws'; +import { getOpenClawDir, getOpenClawEntryPath } from '../utils/paths'; +import { getUvMirrorEnv } from '../utils/uv-env'; +import { isPythonReady, setupManagedPython } from '../utils/uv-setup'; +import { logger } from '../utils/logger'; + +export function warmupManagedPythonReadiness(): void { + void isPythonReady().then((pythonReady) => { + if (!pythonReady) { + logger.info('Python environment missing or incomplete, attempting background repair...'); + void setupManagedPython().catch((err) => { + logger.error('Background Python repair failed:', err); + }); + } + }).catch((err) => { + logger.error('Failed to check Python environment:', err); + }); +} + +export async function terminateOwnedGatewayProcess(child: Electron.UtilityProcess): Promise { + let exited = false; + + await new Promise((resolve) => { + child.once('exit', () => { + exited = true; + resolve(); + }); + + const pid = child.pid; + logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`); + try { + child.kill(); + } catch { + // ignore if already exited + } + + const timeout = setTimeout(() => { + if (!exited) { + logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`); + if (pid) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // ignore + } + } + } + resolve(); + }, 5000); + + child.once('exit', () => { + clearTimeout(timeout); + }); + }); +} + +export async function unloadLaunchctlGatewayService(): Promise { + if (process.platform !== 'darwin') return; + + try { + const uid = process.getuid?.(); + if (uid === undefined) return; + + const launchdLabel = 'ai.openclaw.gateway'; + const serviceTarget = `gui/${uid}/${launchdLabel}`; + const cp = await import('child_process'); + const fsPromises = await import('fs/promises'); + const os = await import('os'); + + const loaded = await new Promise((resolve) => { + cp.exec(`launchctl print ${serviceTarget}`, { timeout: 5000 }, (err) => { + resolve(!err); + }); + }); + + if (!loaded) return; + + logger.info(`Unloading launchctl service ${serviceTarget} to prevent auto-respawn`); + await new Promise((resolve) => { + cp.exec(`launchctl bootout ${serviceTarget}`, { timeout: 10000 }, (err) => { + if (err) { + logger.warn(`Failed to bootout launchctl service: ${err.message}`); + } else { + logger.info('Successfully unloaded launchctl gateway service'); + } + resolve(); + }); + }); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + try { + const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${launchdLabel}.plist`); + await fsPromises.access(plistPath); + await fsPromises.unlink(plistPath); + logger.info(`Removed legacy launchd plist to prevent reload on next login: ${plistPath}`); + } catch { + // File doesn't exist or can't be removed -- not fatal + } + } catch (err) { + logger.warn('Error while unloading launchctl gateway service:', err); + } +} + +export async function waitForPortFree(port: number, timeoutMs = 30000): Promise { + const net = await import('net'); + const start = Date.now(); + const pollInterval = 500; + let logged = false; + + while (Date.now() - start < timeoutMs) { + const available = await new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => { + server.close(() => resolve(true)); + }); + server.listen(port, '127.0.0.1'); + }); + + if (available) { + const elapsed = Date.now() - start; + if (elapsed > pollInterval) { + logger.info(`Port ${port} became available after ${elapsed}ms`); + } + return; + } + + if (!logged) { + logger.info(`Waiting for port ${port} to become available (Windows TCP TIME_WAIT)...`); + logged = true; + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + logger.warn(`Port ${port} still occupied after ${timeoutMs}ms, proceeding anyway`); +} + +async function getListeningProcessIds(port: number): Promise { + const cmd = process.platform === 'win32' + ? `netstat -ano | findstr :${port}` + : `lsof -i :${port} -sTCP:LISTEN -t`; + + const cp = await import('child_process'); + const { stdout } = await new Promise<{ stdout: string }>((resolve) => { + cp.exec(cmd, { timeout: 5000, windowsHide: true }, (err, stdout) => { + if (err) { + resolve({ stdout: '' }); + } else { + resolve({ stdout }); + } + }); + }); + + if (!stdout.trim()) { + return []; + } + + if (process.platform === 'win32') { + const pids: string[] = []; + for (const line of stdout.trim().split(/\r?\n/)) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 5 && parts[3] === 'LISTENING') { + pids.push(parts[4]); + } + } + return [...new Set(pids)]; + } + + return [...new Set(stdout.trim().split(/\r?\n/).map((value) => value.trim()).filter(Boolean))]; +} + +async function terminateOrphanedProcessIds(port: number, pids: string[]): Promise { + logger.info(`Found orphaned process listening on port ${port} (PIDs: ${pids.join(', ')}), attempting to kill...`); + + if (process.platform === 'darwin') { + await unloadLaunchctlGatewayService(); + } + + for (const pid of pids) { + try { + if (process.platform === 'win32') { + const cp = await import('child_process'); + await new Promise((resolve) => { + cp.exec( + `taskkill /F /PID ${pid} /T`, + { timeout: 5000, windowsHide: true }, + () => resolve(), + ); + }); + } else { + process.kill(parseInt(pid, 10), 'SIGTERM'); + } + } catch { + // Ignore processes that have already exited. + } + } + + await new Promise((resolve) => setTimeout(resolve, process.platform === 'win32' ? 2000 : 3000)); + + if (process.platform !== 'win32') { + for (const pid of pids) { + try { + process.kill(parseInt(pid, 10), 0); + process.kill(parseInt(pid, 10), 'SIGKILL'); + } catch { + // Already exited. + } + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} + +export async function findExistingGatewayProcess(options: { + port: number; + ownedPid?: number; +}): Promise<{ port: number; externalToken?: string } | null> { + const { port, ownedPid } = options; + + try { + try { + const pids = await getListeningProcessIds(port); + if (pids.length > 0 && (!ownedPid || !pids.includes(String(ownedPid)))) { + await terminateOrphanedProcessIds(port, pids); + return null; + } + } catch (err) { + logger.warn('Error checking for existing process on port:', err); + } + + return await new Promise<{ port: number; externalToken?: string } | null>((resolve) => { + const testWs = new WebSocket(`ws://localhost:${port}/ws`); + const timeout = setTimeout(() => { + testWs.close(); + resolve(null); + }, 2000); + + testWs.on('open', () => { + clearTimeout(timeout); + testWs.close(); + resolve({ port }); + }); + + testWs.on('error', () => { + clearTimeout(timeout); + resolve(null); + }); + }); + } catch { + return null; + } +} + +export async function runOpenClawDoctorRepair(): Promise { + const openclawDir = getOpenClawDir(); + const entryScript = getOpenClawEntryPath(); + if (!existsSync(entryScript)) { + logger.error(`Cannot run OpenClaw doctor repair: entry script not found at ${entryScript}`); + return false; + } + + const platform = process.platform; + const arch = process.arch; + const target = `${platform}-${arch}`; + const binPath = app.isPackaged + ? path.join(process.resourcesPath, 'bin') + : path.join(process.cwd(), 'resources', 'bin', target); + const binPathExists = existsSync(binPath); + const finalPath = binPathExists + ? `${binPath}${path.delimiter}${process.env.PATH || ''}` + : process.env.PATH || ''; + + const uvEnv = await getUvMirrorEnv(); + const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive']; + logger.info( + `Running OpenClaw doctor repair (entry="${entryScript}", args="${doctorArgs.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`, + ); + + return await new Promise((resolve) => { + const forkEnv: Record = { + ...process.env, + PATH: finalPath, + ...uvEnv, + OPENCLAW_NO_RESPAWN: '1', + }; + + const child = utilityProcess.fork(entryScript, doctorArgs, { + cwd: openclawDir, + stdio: 'pipe', + env: forkEnv as NodeJS.ProcessEnv, + }); + + let settled = false; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + resolve(ok); + }; + + const timeout = setTimeout(() => { + logger.error('OpenClaw doctor repair timed out after 120000ms'); + try { + child.kill(); + } catch { + // ignore + } + finish(false); + }, 120000); + + child.on('error', (err) => { + clearTimeout(timeout); + logger.error('Failed to spawn OpenClaw doctor repair process:', err); + finish(false); + }); + + child.stdout?.on('data', (data) => { + const raw = data.toString(); + for (const line of raw.split(/\r?\n/)) { + const normalized = line.trim(); + if (!normalized) continue; + logger.debug(`[Gateway doctor stdout] ${normalized}`); + } + }); + + child.stderr?.on('data', (data) => { + const raw = data.toString(); + for (const line of raw.split(/\r?\n/)) { + const normalized = line.trim(); + if (!normalized) continue; + logger.warn(`[Gateway doctor stderr] ${normalized}`); + } + }); + + child.on('exit', (code: number) => { + clearTimeout(timeout); + if (code === 0) { + logger.info('OpenClaw doctor repair completed successfully'); + finish(true); + return; + } + logger.warn(`OpenClaw doctor repair exited (code=${code})`); + finish(false); + }); + }); +} diff --git a/electron/gateway/ws-client.ts b/electron/gateway/ws-client.ts new file mode 100644 index 000000000..edfcbb3eb --- /dev/null +++ b/electron/gateway/ws-client.ts @@ -0,0 +1,318 @@ +import WebSocket from 'ws'; +import type { DeviceIdentity } from '../utils/device-identity'; +import type { PendingGatewayRequest } from './request-store'; +import { + buildDeviceAuthPayload, + publicKeyRawBase64UrlFromPem, + signDevicePayload, +} from '../utils/device-identity'; +import { logger } from '../utils/logger'; + +export async function probeGatewayReady( + port: number, + timeoutMs = 1500, +): Promise { + return await new Promise((resolve) => { + const testWs = new WebSocket(`ws://localhost:${port}/ws`); + let settled = false; + + const resolveOnce = (value: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + try { + testWs.close(); + } catch { + // ignore + } + resolve(value); + }; + + const timeout = setTimeout(() => { + resolveOnce(false); + }, timeoutMs); + + testWs.on('open', () => { + // Do not resolve on plain socket open. The gateway can accept the TCP/WebSocket + // connection before it is ready to issue protocol challenges, which previously + // caused a false "ready" result and then a full connect() stall. + }); + + testWs.on('message', (data) => { + try { + const message = JSON.parse(data.toString()) as { type?: string; event?: string }; + if (message.type === 'event' && message.event === 'connect.challenge') { + resolveOnce(true); + } + } catch { + // ignore malformed probe payloads + } + }); + + testWs.on('error', () => { + resolveOnce(false); + }); + + testWs.on('close', () => { + resolveOnce(false); + }); + }); +} + +export async function waitForGatewayReady(options: { + port: number; + getProcessExitCode: () => number | null; + retries?: number; + intervalMs?: number; +}): Promise { + const retries = options.retries ?? 2400; + const intervalMs = options.intervalMs ?? 200; + + for (let i = 0; i < retries; i++) { + const exitCode = options.getProcessExitCode(); + if (exitCode !== null) { + logger.error(`Gateway process exited before ready (code=${exitCode})`); + throw new Error(`Gateway process exited before becoming ready (code=${exitCode})`); + } + + try { + const ready = await probeGatewayReady(options.port, 1500); + if (ready) { + logger.debug(`Gateway ready after ${i + 1} attempt(s)`); + return; + } + } 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, intervalMs)); + } + + logger.error(`Gateway failed to become ready after ${retries} attempts on port ${options.port}`); + throw new Error(`Gateway failed to start after ${retries} retries (port ${options.port})`); +} + +export function buildGatewayConnectFrame(options: { + challengeNonce: string; + token: string; + deviceIdentity: DeviceIdentity | null; + platform: string; +}): { connectId: string; frame: Record } { + const connectId = `connect-${Date.now()}`; + const role = 'operator'; + const scopes = ['operator.admin']; + const signedAtMs = Date.now(); + const clientId = 'gateway-client'; + const clientMode = 'ui'; + + const device = (() => { + if (!options.deviceIdentity) return undefined; + + const payload = buildDeviceAuthPayload({ + deviceId: options.deviceIdentity.deviceId, + clientId, + clientMode, + role, + scopes, + signedAtMs, + token: options.token ?? null, + nonce: options.challengeNonce, + }); + const signature = signDevicePayload(options.deviceIdentity.privateKeyPem, payload); + return { + id: options.deviceIdentity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(options.deviceIdentity.publicKeyPem), + signature, + signedAt: signedAtMs, + nonce: options.challengeNonce, + }; + })(); + + return { + connectId, + frame: { + type: 'req', + id: connectId, + method: 'connect', + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: clientId, + displayName: 'ClawX', + version: '0.1.0', + platform: options.platform, + mode: clientMode, + }, + auth: { + token: options.token, + }, + caps: [], + role, + scopes, + device, + }, + }, + }; +} + +export async function connectGatewaySocket(options: { + port: number; + deviceIdentity: DeviceIdentity | null; + platform: string; + pendingRequests: Map; + getToken: () => Promise; + onHandshakeComplete: (ws: WebSocket) => void; + onMessage: (message: unknown) => void; + onCloseAfterHandshake: () => void; +}): Promise { + logger.debug(`Connecting Gateway WebSocket (ws://localhost:${options.port}/ws)`); + + return await new Promise((resolve, reject) => { + const wsUrl = `ws://localhost:${options.port}/ws`; + const ws = new WebSocket(wsUrl); + let handshakeComplete = false; + let connectId: string | null = null; + let handshakeTimeout: NodeJS.Timeout | null = null; + let challengeTimer: NodeJS.Timeout | null = null; + let challengeReceived = false; + let settled = false; + + const cleanupHandshakeRequest = () => { + if (challengeTimer) { + clearTimeout(challengeTimer); + challengeTimer = null; + } + if (handshakeTimeout) { + clearTimeout(handshakeTimeout); + handshakeTimeout = null; + } + if (connectId && options.pendingRequests.has(connectId)) { + const request = options.pendingRequests.get(connectId); + if (request) { + clearTimeout(request.timeout); + } + options.pendingRequests.delete(connectId); + } + }; + + const resolveOnce = () => { + if (settled) return; + settled = true; + cleanupHandshakeRequest(); + resolve(ws); + }; + + const rejectOnce = (error: unknown) => { + if (settled) return; + settled = true; + cleanupHandshakeRequest(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + + const sendConnectHandshake = async (challengeNonce: string) => { + logger.debug('Sending connect handshake with challenge nonce'); + + const currentToken = await options.getToken(); + const connectPayload = buildGatewayConnectFrame({ + challengeNonce, + token: currentToken, + deviceIdentity: options.deviceIdentity, + platform: options.platform, + }); + connectId = connectPayload.connectId; + + ws.send(JSON.stringify(connectPayload.frame)); + + const requestTimeout = setTimeout(() => { + if (!handshakeComplete) { + logger.error('Gateway connect handshake timed out'); + ws.close(); + rejectOnce(new Error('Connect handshake timeout')); + } + }, 10000); + handshakeTimeout = requestTimeout; + + options.pendingRequests.set(connectId, { + resolve: () => { + handshakeComplete = true; + logger.debug('Gateway connect handshake completed'); + options.onHandshakeComplete(ws); + resolveOnce(); + }, + reject: (error) => { + logger.error('Gateway connect handshake failed:', error); + rejectOnce(error); + }, + timeout: requestTimeout, + }); + }; + + challengeTimer = setTimeout(() => { + if (!challengeReceived && !settled) { + logger.error('Gateway connect.challenge not received within timeout'); + ws.close(); + rejectOnce(new Error('Timed out waiting for connect.challenge from Gateway')); + } + }, 10000); + + ws.on('open', () => { + logger.debug('Gateway WebSocket opened, waiting for connect.challenge...'); + }); + + ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + if ( + !challengeReceived && + typeof message === 'object' && message !== null && + message.type === 'event' && message.event === 'connect.challenge' + ) { + challengeReceived = true; + if (challengeTimer) { + clearTimeout(challengeTimer); + challengeTimer = null; + } + const nonce = message.payload?.nonce as string | undefined; + if (!nonce) { + rejectOnce(new Error('Gateway connect.challenge missing nonce')); + return; + } + logger.debug('Received connect.challenge, sending handshake'); + void sendConnectHandshake(nonce); + return; + } + + options.onMessage(message); + } catch (error) { + logger.debug('Failed to parse Gateway WebSocket message:', error); + } + }); + + 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) { + rejectOnce(new Error(`WebSocket closed before handshake: ${reasonStr}`)); + return; + } + cleanupHandshakeRequest(); + options.onCloseAfterHandshake(); + }); + + ws.on('error', (error) => { + 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); + } + }); + }); +} diff --git a/electron/main/index.ts b/electron/main/index.ts index b387c40b3..289d3136e 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -3,6 +3,7 @@ * Manages window creation, system tray, and IPC handlers */ import { app, BrowserWindow, nativeImage, session, shell } from 'electron'; +import type { Server } from 'node:http'; import { join } from 'path'; import { GatewayManager } from '../gateway/manager'; import { registerIpcHandlers } from './ipc-handlers'; @@ -20,6 +21,12 @@ import { isQuitting, setQuitting } from './app-state'; import { applyProxySettings } from './proxy'; import { getSetting } from '../utils/store'; import { ensureBuiltinSkillsInstalled } from '../utils/skill-config'; +import { startHostApiServer } from '../api/server'; +import { HostEventBus } from '../api/event-bus'; +import { deviceOAuthManager } from '../utils/device-oauth'; +import { browserOAuthManager } from '../utils/browser-oauth'; +import { whatsAppLoginManager } from '../utils/whatsapp-login'; +import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync'; // Disable GPU hardware acceleration globally for maximum stability across // all GPU configurations (no GPU, integrated, discrete). @@ -58,6 +65,8 @@ if (!gotTheLock) { let mainWindow: BrowserWindow | null = null; const gatewayManager = new GatewayManager(); const clawHubService = new ClawHubService(); +const hostEventBus = new HostEventBus(); +let hostApiServer: Server | null = null; /** * Resolve the icons directory path (works in both dev and packaged mode) @@ -185,6 +194,13 @@ async function initialize(): Promise { // Register IPC handlers registerIpcHandlers(gatewayManager, clawHubService, mainWindow); + hostApiServer = startHostApiServer({ + gatewayManager, + clawHubService, + eventBus: hostEventBus, + mainWindow, + }); + // Register update handlers registerUpdateHandlers(appUpdater, mainWindow); @@ -216,10 +232,82 @@ async function initialize(): Promise { logger.warn('Failed to install built-in skills:', error); }); + // Bridge gateway and host-side events before any auto-start logic runs, so + // renderer subscribers observe the full startup lifecycle. + gatewayManager.on('status', (status: { state: string }) => { + hostEventBus.emit('gateway:status', status); + if (status.state === 'running') { + void ensureClawXContext().catch((error) => { + logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error); + }); + } + }); + + gatewayManager.on('error', (error) => { + hostEventBus.emit('gateway:error', { message: error.message }); + }); + + gatewayManager.on('notification', (notification) => { + hostEventBus.emit('gateway:notification', notification); + }); + + gatewayManager.on('chat:message', (data) => { + hostEventBus.emit('gateway:chat-message', data); + }); + + gatewayManager.on('channel:status', (data) => { + hostEventBus.emit('gateway:channel-status', data); + }); + + gatewayManager.on('exit', (code) => { + hostEventBus.emit('gateway:exit', { code }); + }); + + deviceOAuthManager.on('oauth:code', (payload) => { + hostEventBus.emit('oauth:code', payload); + }); + + deviceOAuthManager.on('oauth:start', (payload) => { + hostEventBus.emit('oauth:start', payload); + }); + + deviceOAuthManager.on('oauth:success', (payload) => { + hostEventBus.emit('oauth:success', { ...payload, success: true }); + }); + + deviceOAuthManager.on('oauth:error', (error) => { + hostEventBus.emit('oauth:error', error); + }); + + browserOAuthManager.on('oauth:start', (payload) => { + hostEventBus.emit('oauth:start', payload); + }); + + browserOAuthManager.on('oauth:success', (payload) => { + hostEventBus.emit('oauth:success', { ...payload, success: true }); + }); + + browserOAuthManager.on('oauth:error', (error) => { + hostEventBus.emit('oauth:error', error); + }); + + whatsAppLoginManager.on('qr', (data) => { + hostEventBus.emit('channel:whatsapp-qr', data); + }); + + whatsAppLoginManager.on('success', (data) => { + hostEventBus.emit('channel:whatsapp-success', data); + }); + + whatsAppLoginManager.on('error', (error) => { + hostEventBus.emit('channel:whatsapp-error', error); + }); + // Start Gateway automatically (this seeds missing bootstrap files with full templates) const gatewayAutoStart = await getSetting('gatewayAutoStart'); if (gatewayAutoStart) { try { + await syncAllProviderAuthToRuntime(); logger.debug('Auto-starting Gateway...'); await gatewayManager.start(); logger.info('Gateway auto-start succeeded'); @@ -247,16 +335,6 @@ async function initialize(): Promise { }).catch((error) => { logger.warn('CLI auto-install failed:', error); }); - - // Re-apply ClawX context after every gateway restart because the gateway - // may re-seed workspace files with clean templates (losing ClawX markers). - gatewayManager.on('status', (status: { state: string }) => { - if (status.state === 'running') { - void ensureClawXContext().catch((error) => { - logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error); - }); - } - }); } // When a second instance is launched, focus the existing window instead. @@ -293,6 +371,8 @@ app.on('window-all-closed', () => { app.on('before-quit', () => { setQuitting(); + hostEventBus.closeAll(); + hostApiServer?.close(); // Fire-and-forget: do not await gatewayManager.stop() here. // Awaiting inside before-quit can stall Electron's quit sequence. void gatewayManager.stop().catch((err) => { diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 4436c7ba3..9e84c41a1 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -10,17 +10,6 @@ import crypto from 'node:crypto'; import { GatewayManager } from '../gateway/manager'; import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub'; import { - storeApiKey, - getApiKey, - deleteApiKey, - hasApiKey, - saveProvider, - getProvider, - getAllProviders, - deleteProvider, - setDefaultProvider, - getDefaultProvider, - getAllProvidersWithKeyInfo, type ProviderConfig, } from '../utils/secure-storage'; import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir, ensureDir } from '../utils/paths'; @@ -29,10 +18,6 @@ import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings import { saveProviderKeyToOpenClaw, removeProviderFromOpenClaw, - setOpenClawDefaultModel, - setOpenClawDefaultModelWithOverride, - syncProviderConfigToOpenClaw, - updateAgentModelProvider, } from '../utils/openclaw-auth'; import { logger } from '../utils/logger'; import { @@ -49,11 +34,21 @@ import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-set import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config'; import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { getProviderConfig } from '../utils/provider-registry'; -import { getProviderDefaultModel } from '../utils/provider-registry'; import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; +import { browserOAuthManager, type BrowserOAuthProviderType } from '../utils/browser-oauth'; import { applyProxySettings } from './proxy'; -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'; type AppRequest = { @@ -73,79 +68,6 @@ type AppResponse = { details?: unknown; }; }; -import { - getOpenClawProviderKeyForType, - getOAuthApiKeyEnv, - getOAuthProviderApi, - getOAuthProviderDefaultBaseUrl, - getOAuthProviderTargetKey, - isOAuthProviderType, - normalizeOAuthBaseUrl, - usesOAuthAuthHeader, -} from '../utils/provider-keys'; - -/** - * Derive OpenClaw provider key used in openclaw.json / models.json. - * Some types need remapping to avoid collisions or enforce CN endpoints. - * - * @param type - Provider type (e.g. 'custom', 'ollama', 'openrouter') - * @param providerId - Unique provider ID from secure-storage (UUID-like) - * @returns A key like 'custom-a1b2c3d4', 'moonshot', or 'openrouter' - */ -export function getOpenClawProviderKey(type: string, providerId: string): string { - return getOpenClawProviderKeyForType(type, providerId); -} - -function getProviderModelRef(config: ProviderConfig): string | undefined { - const providerKey = getOpenClawProviderKey(config.type, config.id); - - if (config.model) { - return config.model.startsWith(`${providerKey}/`) - ? config.model - : `${providerKey}/${config.model}`; - } - - const defaultModel = getProviderDefaultModel(config.type); - if (!defaultModel) return undefined; - const modelId = defaultModel.includes('/') ? defaultModel.split('/').slice(1).join('/') : defaultModel; - return `${providerKey}/${modelId}`; -} - -async function getProviderFallbackModelRefs(config: ProviderConfig): Promise { - const allProviders = await getAllProviders(); - const providerMap = new Map(allProviders.map((provider) => [provider.id, provider])); - const seen = new Set(); - const results: string[] = []; - const providerKey = getOpenClawProviderKey(config.type, config.id); - - for (const fallbackModel of config.fallbackModels ?? []) { - const normalizedModel = fallbackModel.trim(); - if (!normalizedModel) continue; - - const modelRef = normalizedModel.startsWith(`${providerKey}/`) - ? normalizedModel - : `${providerKey}/${normalizedModel}`; - - if (seen.has(modelRef)) continue; - seen.add(modelRef); - results.push(modelRef); - } - - for (const fallbackId of config.fallbackProviderIds ?? []) { - if (!fallbackId || fallbackId === config.id) continue; - - const fallbackProvider = providerMap.get(fallbackId); - if (!fallbackProvider) continue; - - const modelRef = getProviderModelRef(fallbackProvider); - if (!modelRef || seen.has(modelRef)) continue; - - seen.add(modelRef); - results.push(modelRef); - } - - return results; -} /** * Register all IPC handlers @@ -330,50 +252,16 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { try { await saveProvider(config); - const ock = getOpenClawProviderKey(config.type, config.id); if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { await storeApiKey(config.id, trimmedKey); - try { - await saveProviderKeyToOpenClaw(ock, trimmedKey); - } catch (err) { - console.warn('Failed to save key to OpenClaw auth-profiles:', err); - } } } try { - const meta = getProviderConfig(config.type); - const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; - - if (api) { - await syncProviderConfigToOpenClaw(ock, config.model, { - baseUrl: config.baseUrl || meta?.baseUrl, - api, - apiKeyEnv: meta?.apiKeyEnv, - headers: meta?.headers, - }); - - if (config.type === 'custom' || config.type === 'ollama') { - const resolvedKey = apiKey !== undefined - ? (apiKey.trim() || null) - : await getApiKey(config.id); - if (resolvedKey && config.baseUrl) { - const modelId = config.model; - await updateAgentModelProvider(ock, { - baseUrl: config.baseUrl, - api: 'openai-completions', - models: modelId ? [{ id: modelId, name: modelId }] : [], - apiKey: resolvedKey, - }); - } - } - - logger.info(`Scheduling Gateway restart after saving provider "${ock}" config`); - gatewayManager.debouncedRestart(); - } + await syncSavedProviderToRuntime(config, apiKey, gatewayManager); } catch (err) { console.warn('Failed to sync openclaw provider config:', err); } @@ -394,10 +282,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { await deleteProvider(providerId); if (existing?.type) { try { - const ock = getOpenClawProviderKey(existing.type, providerId); - await removeProviderFromOpenClaw(ock); - logger.info(`Scheduling Gateway restart after deleting provider "${ock}"`); - gatewayManager.debouncedRestart(); + await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); } catch (err) { console.warn('Failed to completely remove provider from OpenClaw:', err); } @@ -473,49 +358,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { } try { - const fallbackModels = await getProviderFallbackModelRefs(nextConfig); - const meta = getProviderConfig(nextConfig.type); - const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api; - - if (api) { - await syncProviderConfigToOpenClaw(ock, nextConfig.model, { - baseUrl: nextConfig.baseUrl || meta?.baseUrl, - api, - apiKeyEnv: meta?.apiKeyEnv, - headers: meta?.headers, - }); - - if (nextConfig.type === 'custom' || nextConfig.type === 'ollama') { - const resolvedKey = apiKey !== undefined - ? (apiKey.trim() || null) - : await getApiKey(providerId); - if (resolvedKey && nextConfig.baseUrl) { - const modelId = nextConfig.model; - await updateAgentModelProvider(ock, { - baseUrl: nextConfig.baseUrl, - api: 'openai-completions', - models: modelId ? [{ id: modelId, name: modelId }] : [], - apiKey: resolvedKey, - }); - } - } - } - - const defaultProviderId = await getDefaultProvider(); - if (defaultProviderId === providerId) { - const modelOverride = nextConfig.model ? `${ock}/${nextConfig.model}` : undefined; - if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') { - await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); - } else { - await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: nextConfig.baseUrl, - api: 'openai-completions', - }, fallbackModels); - } - } - - logger.info(`Scheduling Gateway restart after updating provider "${ock}" config`); - gatewayManager.debouncedRestart(); + await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager); } catch (err) { console.warn('Failed to sync openclaw config after provider update:', err); } @@ -571,84 +414,7 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void { const provider = await getProvider(providerId); if (provider) { try { - const ock = getOpenClawProviderKey(provider.type, providerId); - const providerKey = await getApiKey(providerId); - const fallbackModels = await getProviderFallbackModelRefs(provider); - const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; - const isOAuthProvider = OAUTH_PROVIDER_TYPES.includes(provider.type) && !providerKey; - - if (!isOAuthProvider) { - const modelOverride = provider.model - ? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`) - : undefined; - if (provider.type === 'custom' || provider.type === 'ollama') { - await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: provider.baseUrl, - api: 'openai-completions', - }, fallbackModels); - } else { - await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); - } - if (providerKey) { - await saveProviderKeyToOpenClaw(ock, providerKey); - } - } else { - const defaultBaseUrl = provider.type === 'minimax-portal' - ? 'https://api.minimax.io/anthropic' - : (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1'); - const api: 'anthropic-messages' | 'openai-completions' = - (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') - ? 'anthropic-messages' - : 'openai-completions'; - - let baseUrl = provider.baseUrl || defaultBaseUrl; - if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) { - baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; - } - - const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') - ? 'minimax-portal' - : provider.type; - - await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), { - baseUrl, - api, - authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, - apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', - }, fallbackModels); - - try { - const defaultModelId = provider.model?.split('/').pop(); - await updateAgentModelProvider(targetProviderKey, { - baseUrl, - api, - authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, - apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', - models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [], - }); - } catch (err) { - logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err); - } - } - - if ( - (provider.type === 'custom' || provider.type === 'ollama') && - providerKey && - provider.baseUrl - ) { - const modelId = provider.model; - await updateAgentModelProvider(ock, { - baseUrl: provider.baseUrl, - api: 'openai-completions', - models: modelId ? [{ id: modelId, name: modelId }] : [], - apiKey: providerKey, - }); - } - - if (gatewayManager.getStatus().state !== 'stopped') { - logger.info(`Scheduling Gateway restart after provider switch to "${ock}"`); - gatewayManager.debouncedRestart(); - } + await syncDefaultProviderToRuntime(providerId, gatewayManager); } catch (err) { console.warn('Failed to set OpenClaw default model:', err); } @@ -1777,23 +1543,37 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void { */ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { deviceOAuthManager.setWindow(mainWindow); + browserOAuthManager.setWindow(mainWindow); // Request Provider OAuth initialization - ipcMain.handle('provider:requestOAuth', async (_, provider: OAuthProviderType, region?: 'global' | 'cn') => { - try { - logger.info(`provider:requestOAuth for ${provider}`); - await deviceOAuthManager.startFlow(provider, region); - return { success: true }; - } catch (error) { - logger.error('provider:requestOAuth failed', error); - return { success: false, error: String(error) }; - } - }); + 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 === 'google') { + 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); @@ -1806,44 +1586,60 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void { * Provider-related IPC handlers */ function registerProviderHandlers(gatewayManager: GatewayManager): void { + const providerService = getProviderService(); + // Listen for OAuth success to automatically restart the Gateway with new tokens/configs. // Use a longer debounce (8s) so that provider:setDefault — which writes the full config // and then calls debouncedRestart(2s) — has time to fire and coalesce into a single // restart. Without this, the OAuth restart fires first with stale config, and the // subsequent provider:setDefault restart is deferred and dropped. - deviceOAuthManager.on('oauth:success', (providerType) => { - logger.info(`[IPC] Scheduling Gateway restart after ${providerType} OAuth success...`); + 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 () => { - return await getAllProvidersWithKeyInfo(); + 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) => { - return await getProvider(providerId); + return await providerService.getLegacyProvider(providerId); }); // Save a provider configuration ipcMain.handle('provider:save', async (_, config: ProviderConfig, apiKey?: string) => { try { // Save the provider config - await saveProvider(config); - - // Derive the unique OpenClaw key for this provider instance - const ock = getOpenClawProviderKey(config.type, config.id); + await providerService.saveLegacyProvider(config); // Store the API key if provided if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { - await storeApiKey(config.id, trimmedKey); + await providerService.setLegacyProviderApiKey(config.id, trimmedKey); // Also write to OpenClaw auth-profiles.json so the gateway can use it try { - await saveProviderKeyToOpenClaw(ock, trimmedKey); + await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey); } catch (err) { console.warn('Failed to save key to OpenClaw auth-profiles:', err); } @@ -1852,37 +1648,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Sync the provider configuration to openclaw.json so Gateway knows about it try { - const meta = getProviderConfig(config.type); - const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; - - if (api) { - await syncProviderConfigToOpenClaw(ock, config.model, { - baseUrl: config.baseUrl || meta?.baseUrl, - api, - apiKeyEnv: meta?.apiKeyEnv, - headers: meta?.headers, - }); - - if (config.type === 'custom' || config.type === 'ollama') { - const resolvedKey = apiKey !== undefined - ? (apiKey.trim() || null) - : await getApiKey(config.id); - if (resolvedKey && config.baseUrl) { - const modelId = config.model; - await updateAgentModelProvider(ock, { - baseUrl: config.baseUrl, - api: 'openai-completions', - models: modelId ? [{ id: modelId, name: modelId }] : [], - apiKey: resolvedKey, - }); - } - } - - // Debounced restart so the gateway picks up new config/env vars. - // Multiple rapid provider saves (e.g. during setup) are coalesced. - logger.info(`Scheduling Gateway restart after saving provider "${ock}" config`); - gatewayManager.debouncedRestart(); - } + await syncSavedProviderToRuntime(config, apiKey, gatewayManager); } catch (err) { console.warn('Failed to sync openclaw provider config:', err); } @@ -1896,18 +1662,13 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Delete a provider ipcMain.handle('provider:delete', async (_, providerId: string) => { try { - const existing = await getProvider(providerId); - await deleteProvider(providerId); + const existing = await providerService.getLegacyProvider(providerId); + await providerService.deleteLegacyProvider(providerId); // Best-effort cleanup in OpenClaw auth profiles & openclaw.json config if (existing?.type) { try { - const ock = getOpenClawProviderKey(existing.type, providerId); - await removeProviderFromOpenClaw(ock); - - // Debounced restart so the gateway stops loading the deleted provider. - logger.info(`Scheduling Gateway restart after deleting provider "${ock}"`); - gatewayManager.debouncedRestart(); + await syncDeletedProviderToRuntime(existing, providerId, gatewayManager); } catch (err) { console.warn('Failed to completely remove provider from OpenClaw:', err); } @@ -1922,14 +1683,13 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Update API key for a provider ipcMain.handle('provider:setApiKey', async (_, providerId: string, apiKey: string) => { try { - await storeApiKey(providerId, apiKey); + await providerService.setLegacyProviderApiKey(providerId, apiKey); // Also write to OpenClaw auth-profiles.json - const provider = await getProvider(providerId); + const provider = await providerService.getLegacyProvider(providerId); const providerType = provider?.type || providerId; - const ock = getOpenClawProviderKey(providerType, providerId); try { - await saveProviderKeyToOpenClaw(ock, apiKey); + await syncProviderApiKeyToRuntime(providerType, providerId, apiKey); } catch (err) { console.warn('Failed to save key to OpenClaw auth-profiles:', err); } @@ -1949,12 +1709,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { updates: Partial, apiKey?: string ) => { - const existing = await getProvider(providerId); + const existing = await providerService.getLegacyProvider(providerId); if (!existing) { return { success: false, error: 'Provider not found' }; } - const previousKey = await getApiKey(providerId); + const previousKey = await providerService.getLegacyProviderApiKey(providerId); const previousOck = getOpenClawProviderKey(existing.type, providerId); try { @@ -1966,72 +1726,22 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { const ock = getOpenClawProviderKey(nextConfig.type, providerId); - await saveProvider(nextConfig); + await providerService.saveLegacyProvider(nextConfig); if (apiKey !== undefined) { const trimmedKey = apiKey.trim(); if (trimmedKey) { - await storeApiKey(providerId, trimmedKey); - await saveProviderKeyToOpenClaw(ock, trimmedKey); + await providerService.setLegacyProviderApiKey(providerId, trimmedKey); + await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey); } else { - await deleteApiKey(providerId); + await providerService.deleteLegacyProviderApiKey(providerId); await removeProviderFromOpenClaw(ock); } } // Sync the provider configuration to openclaw.json so Gateway knows about it try { - const fallbackModels = await getProviderFallbackModelRefs(nextConfig); - const meta = getProviderConfig(nextConfig.type); - const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api; - - if (api) { - await syncProviderConfigToOpenClaw(ock, nextConfig.model, { - baseUrl: nextConfig.baseUrl || meta?.baseUrl, - api, - apiKeyEnv: meta?.apiKeyEnv, - headers: meta?.headers, - }); - - if (nextConfig.type === 'custom' || nextConfig.type === 'ollama') { - const resolvedKey = apiKey !== undefined - ? (apiKey.trim() || null) - : await getApiKey(providerId); - if (resolvedKey && nextConfig.baseUrl) { - const modelId = nextConfig.model; - await updateAgentModelProvider(ock, { - baseUrl: nextConfig.baseUrl, - api: 'openai-completions', - models: modelId ? [{ id: modelId, name: modelId }] : [], - apiKey: resolvedKey, - }); - } - } - } - - // If this provider is the current default, update the primary model - const defaultProviderId = await getDefaultProvider(); - if (defaultProviderId === providerId) { - const modelOverride = getProviderModelRef(nextConfig); - const providerKeyIsAliased = ock !== nextConfig.type; - if (nextConfig.type === 'custom' || nextConfig.type === 'ollama' || providerKeyIsAliased) { - const baseMeta = getProviderConfig(nextConfig.type); - await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: nextConfig.baseUrl || baseMeta?.baseUrl, - api: nextConfig.type === 'custom' || nextConfig.type === 'ollama' - ? 'openai-completions' - : baseMeta?.api, - apiKeyEnv: baseMeta?.apiKeyEnv, - headers: baseMeta?.headers, - }, fallbackModels); - } else { - await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); - } - } - - // Debounced restart so the gateway picks up updated config/env vars. - logger.info(`Scheduling Gateway restart after updating provider "${ock}" config`); - gatewayManager.debouncedRestart(); + await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager); } catch (err) { console.warn('Failed to sync openclaw config after provider update:', err); } @@ -2040,12 +1750,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { } catch (error) { // Best-effort rollback to keep config/key consistent. try { - await saveProvider(existing); + await providerService.saveLegacyProvider(existing); if (previousKey) { - await storeApiKey(providerId, previousKey); + await providerService.setLegacyProviderApiKey(providerId, previousKey); await saveProviderKeyToOpenClaw(previousOck, previousKey); } else { - await deleteApiKey(providerId); + await providerService.deleteLegacyProviderApiKey(providerId); await removeProviderFromOpenClaw(previousOck); } } catch (rollbackError) { @@ -2060,16 +1770,12 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Delete API key for a provider ipcMain.handle('provider:deleteApiKey', async (_, providerId: string) => { try { - await deleteApiKey(providerId); + await providerService.deleteLegacyProviderApiKey(providerId); // Keep OpenClaw auth-profiles.json in sync with local key storage - const provider = await getProvider(providerId); - const providerType = provider?.type || providerId; - const ock = getOpenClawProviderKey(providerType, providerId); + const provider = await providerService.getLegacyProvider(providerId); try { - if (ock) { - await removeProviderFromOpenClaw(ock); - } + await syncDeletedProviderApiKeyToRuntime(provider, providerId); } catch (err) { console.warn('Failed to completely remove provider from OpenClaw:', err); } @@ -2082,116 +1788,24 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Check if a provider has an API key ipcMain.handle('provider:hasApiKey', async (_, providerId: string) => { - return await hasApiKey(providerId); + return await providerService.hasLegacyProviderApiKey(providerId); }); // Get the actual API key (for internal use only - be careful!) ipcMain.handle('provider:getApiKey', async (_, providerId: string) => { - return await getApiKey(providerId); + return await providerService.getLegacyProviderApiKey(providerId); }); // Set default provider and update OpenClaw default model ipcMain.handle('provider:setDefault', async (_, providerId: string) => { try { - await setDefaultProvider(providerId); + await providerService.setDefaultLegacyProvider(providerId); // Update OpenClaw config to use this provider's default model - const provider = await getProvider(providerId); - if (provider) { - try { - const ock = getOpenClawProviderKey(provider.type, providerId); - const providerKey = await getApiKey(providerId); - const fallbackModels = await getProviderFallbackModelRefs(provider); - - // OAuth providers might use OAuth OR a direct API key. - // Treat them as OAuth-only if they don't have a local API key configured. - const isOAuthProvider = isOAuthProviderType(provider.type) && !providerKey; - - if (!isOAuthProvider) { - // Build the model reference from provider settings/default mapping. - const modelOverride = getProviderModelRef(provider); - const providerKeyIsAliased = ock !== provider.type; - if (provider.type === 'custom' || provider.type === 'ollama' || providerKeyIsAliased) { - const baseMeta = getProviderConfig(provider.type); - await setOpenClawDefaultModelWithOverride(ock, modelOverride, { - baseUrl: provider.baseUrl || baseMeta?.baseUrl, - api: provider.type === 'custom' || provider.type === 'ollama' - ? 'openai-completions' - : baseMeta?.api, - apiKeyEnv: baseMeta?.apiKeyEnv, - headers: baseMeta?.headers, - }, fallbackModels); - } else { - await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); - } - - // Keep auth-profiles in sync with the default provider instance. - if (providerKey) { - await saveProviderKeyToOpenClaw(ock, providerKey); - } - } else { - const defaultBaseUrl = getOAuthProviderDefaultBaseUrl(provider.type); - const api = getOAuthProviderApi(provider.type); - const targetProviderKey = getOAuthProviderTargetKey(provider.type); - const baseUrl = normalizeOAuthBaseUrl(provider.type, provider.baseUrl || defaultBaseUrl); - const oauthApiKeyEnv = targetProviderKey ? getOAuthApiKeyEnv(targetProviderKey) : undefined; - const oauthUsesAuthHeader = targetProviderKey ? usesOAuthAuthHeader(targetProviderKey) : false; - if (!baseUrl || !api || !targetProviderKey || !oauthApiKeyEnv) { - throw new Error(`Invalid OAuth provider config for "${provider.type}"`); - } - - await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), { - baseUrl, - api, - authHeader: oauthUsesAuthHeader ? true : undefined, - // Relies on OpenClaw Gateway native auth-profiles syncing - apiKeyEnv: oauthApiKeyEnv, - }, fallbackModels); - - logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`); - - // Also write models.json directly so pi-ai picks up the correct baseUrl and - // authHeader immediately, without waiting for Gateway to sync openclaw.json. - try { - const defaultModelId = provider.model?.split('/').pop(); - await updateAgentModelProvider(targetProviderKey, { - baseUrl, - api, - authHeader: oauthUsesAuthHeader ? true : undefined, - apiKey: oauthApiKeyEnv, - models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [], - }); - } catch (err) { - logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err); - } - } - - // For custom/ollama providers, also update the per-agent models.json - if ( - (provider.type === 'custom' || provider.type === 'ollama') && - providerKey && - provider.baseUrl - ) { - const modelId = provider.model; - await updateAgentModelProvider(ock, { - baseUrl: provider.baseUrl, - api: 'openai-completions', - models: modelId ? [{ id: modelId, name: modelId }] : [], - apiKey: providerKey, - }); - } - - // Debounced restart so the gateway picks up the new default provider. - // Because OAuth success triggers a debounced restart, the gateway might not be - // currently connected ('starting' or 'reconnecting'). Checking if it is simply - // not 'stopped' ensures the restart request is correctly queued or coalesced. - if (gatewayManager.getStatus().state !== 'stopped') { - logger.info(`Scheduling Gateway restart after provider switch to "${ock}"`); - gatewayManager.debouncedRestart(); - } - } catch (err) { - console.warn('Failed to set OpenClaw default model:', err); - } + try { + await syncDefaultProviderToRuntime(providerId, gatewayManager); + } catch (err) { + console.warn('Failed to set OpenClaw default model:', err); } return { success: true }; @@ -2204,7 +1818,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { // Get default provider ipcMain.handle('provider:getDefault', async () => { - return await getDefaultProvider(); + return await providerService.getDefaultLegacyProvider(); }); // Validate API key by making a real test request to the provider. @@ -2219,7 +1833,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { ) => { try { // First try to get existing provider - const provider = await getProvider(providerId); + 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 @@ -2239,266 +1853,6 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void { ); } -type ValidationProfile = 'openai-compatible' | 'google-query-key' | 'anthropic-header' | 'openrouter' | '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); - case 'openrouter': - return await validateOpenRouterKey(providerType, trimmedKey); - 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): Record { - 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 -): 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 'openrouter': - return 'openrouter'; - case 'ollama': - return 'none'; - default: - return 'openai-compatible'; - } -} - -async function performProviderValidationRequest( - providerLabel: string, - url: string, - headers: Record -): Promise<{ valid: boolean; error?: string }> { - try { - logValidationRequest(providerLabel, 'GET', url, headers); - const response = await proxyAwareFetch(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 -): Promise<{ valid: boolean; error?: string }> { - try { - logValidationRequest(providerLabel, 'POST', url, headers); - const response = await proxyAwareFetch(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 }> { - // Default to the official Google Gemini API base URL if none is provided - const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta'); - 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); -} - -async function validateOpenRouterKey( - providerType: string, - apiKey: string -): Promise<{ valid: boolean; error?: string }> { - // Use OpenRouter's auth check endpoint instead of public /models - const url = 'https://openrouter.ai/api/v1/auth/key'; - const headers = { Authorization: `Bearer ${apiKey}` }; - return await performProviderValidationRequest(providerType, url, headers); -} - /** * Shell-related IPC handlers */ @@ -3068,4 +2422,4 @@ function registerSessionHandlers(): void { return { success: false, error: String(err) }; } }); -} +} \ No newline at end of file diff --git a/electron/services/providers/provider-migration.ts b/electron/services/providers/provider-migration.ts new file mode 100644 index 000000000..83b29892b --- /dev/null +++ b/electron/services/providers/provider-migration.ts @@ -0,0 +1,35 @@ +import type { ProviderConfig } from '../../shared/providers/types'; +import { + getDefaultProviderAccountId, + providerConfigToAccount, + saveProviderAccount, +} from './provider-store'; +import { getClawXProviderStore } from './store-instance'; + +const PROVIDER_STORE_SCHEMA_VERSION = 1; + +export async function ensureProviderStoreMigrated(): Promise { + const store = await getClawXProviderStore(); + const schemaVersion = Number(store.get('schemaVersion') ?? 0); + + if (schemaVersion >= PROVIDER_STORE_SCHEMA_VERSION) { + return; + } + + const legacyProviders = (store.get('providers') ?? {}) as Record; + const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null; + const existingDefaultAccountId = await getDefaultProviderAccountId(); + + for (const provider of Object.values(legacyProviders)) { + const account = providerConfigToAccount(provider, { + isDefault: provider.id === defaultProviderId, + }); + await saveProviderAccount(account); + } + + if (!existingDefaultAccountId && defaultProviderId) { + store.set('defaultProviderAccountId', defaultProviderId); + } + + store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION); +} diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts new file mode 100644 index 000000000..d25b1c731 --- /dev/null +++ b/electron/services/providers/provider-runtime-sync.ts @@ -0,0 +1,460 @@ +import type { GatewayManager } from '../../gateway/manager'; +import { getProviderAccount, listProviderAccounts } from './provider-store'; +import { getProviderSecret } from '../secrets/secret-store'; +import type { ProviderConfig } from '../../utils/secure-storage'; +import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage'; +import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry'; +import { + removeProviderFromOpenClaw, + saveOAuthTokenToOpenClaw, + saveProviderKeyToOpenClaw, + setOpenClawDefaultModel, + setOpenClawDefaultModelWithOverride, + syncProviderConfigToOpenClaw, + updateAgentModelProvider, +} from '../../utils/openclaw-auth'; +import { logger } from '../../utils/logger'; + +const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli'; +const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`; + +export function getOpenClawProviderKey(type: string, providerId: string): string { + if (type === 'custom' || type === 'ollama') { + const suffix = providerId.replace(/-/g, '').slice(0, 8); + return `${type}-${suffix}`; + } + if (type === 'minimax-portal-cn') { + return 'minimax-portal'; + } + return type; +} + +async function resolveRuntimeProviderKey(config: ProviderConfig): Promise { + const account = await getProviderAccount(config.id); + if (config.type === 'google' && account?.authMode === 'oauth_browser') { + return GOOGLE_OAUTH_RUNTIME_PROVIDER; + } + return getOpenClawProviderKey(config.type, config.id); +} + +async function isGoogleBrowserOAuthProvider(config: ProviderConfig): Promise { + const account = await getProviderAccount(config.id); + if (config.type !== 'google' || account?.authMode !== 'oauth_browser') { + return false; + } + + const secret = await getProviderSecret(config.id); + return secret?.type === 'oauth'; +} + +export function getProviderModelRef(config: ProviderConfig): string | undefined { + const providerKey = getOpenClawProviderKey(config.type, config.id); + + if (config.model) { + return config.model.startsWith(`${providerKey}/`) + ? config.model + : `${providerKey}/${config.model}`; + } + + const defaultModel = getProviderDefaultModel(config.type); + if (!defaultModel) { + return undefined; + } + + return defaultModel.startsWith(`${providerKey}/`) + ? defaultModel + : `${providerKey}/${defaultModel}`; +} + +export async function getProviderFallbackModelRefs(config: ProviderConfig): Promise { + const allProviders = await getAllProviders(); + const providerMap = new Map(allProviders.map((provider) => [provider.id, provider])); + const seen = new Set(); + const results: string[] = []; + const providerKey = getOpenClawProviderKey(config.type, config.id); + + for (const fallbackModel of config.fallbackModels ?? []) { + const normalizedModel = fallbackModel.trim(); + if (!normalizedModel) continue; + + const modelRef = normalizedModel.startsWith(`${providerKey}/`) + ? normalizedModel + : `${providerKey}/${normalizedModel}`; + + if (seen.has(modelRef)) continue; + seen.add(modelRef); + results.push(modelRef); + } + + for (const fallbackId of config.fallbackProviderIds ?? []) { + if (!fallbackId || fallbackId === config.id) continue; + + const fallbackProvider = providerMap.get(fallbackId); + if (!fallbackProvider) continue; + + const modelRef = getProviderModelRef(fallbackProvider); + if (!modelRef || seen.has(modelRef)) continue; + + seen.add(modelRef); + results.push(modelRef); + } + + return results; +} + +function scheduleGatewayRestart( + gatewayManager: GatewayManager | undefined, + message: string, + options?: { delayMs?: number; onlyIfRunning?: boolean }, +): void { + if (!gatewayManager) { + return; + } + + if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') { + return; + } + + logger.info(message); + gatewayManager.debouncedRestart(options?.delayMs); +} + +export async function syncProviderApiKeyToRuntime( + providerType: string, + providerId: string, + apiKey: string, +): Promise { + const ock = getOpenClawProviderKey(providerType, providerId); + await saveProviderKeyToOpenClaw(ock, apiKey); +} + +export async function syncAllProviderAuthToRuntime(): Promise { + const accounts = await listProviderAccounts(); + + for (const account of accounts) { + const runtimeProviderKey = await resolveRuntimeProviderKey({ + id: account.id, + name: account.label, + type: account.vendorId, + baseUrl: account.baseUrl, + model: account.model, + fallbackModels: account.fallbackModels, + fallbackProviderIds: account.fallbackAccountIds, + enabled: account.enabled, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + }); + + const secret = await getProviderSecret(account.id); + if (!secret) { + continue; + } + + if (secret.type === 'api_key') { + await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey); + continue; + } + + if (secret.type === 'local' && secret.apiKey) { + await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey); + continue; + } + + if (secret.type === 'oauth') { + await saveOAuthTokenToOpenClaw(runtimeProviderKey, { + access: secret.accessToken, + refresh: secret.refreshToken, + expires: secret.expiresAt, + email: secret.email, + projectId: secret.subject, + }); + } + } +} + +export async function syncSavedProviderToRuntime( + config: ProviderConfig, + apiKey: string | undefined, + gatewayManager?: GatewayManager, +): Promise { + const ock = await resolveRuntimeProviderKey(config); + const secret = await getProviderSecret(config.id); + + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await saveProviderKeyToOpenClaw(ock, trimmedKey); + } + } else if (secret?.type === 'api_key') { + await saveProviderKeyToOpenClaw(ock, secret.apiKey); + } else if (secret?.type === 'oauth') { + await saveOAuthTokenToOpenClaw(ock, { + access: secret.accessToken, + refresh: secret.refreshToken, + expires: secret.expiresAt, + email: secret.email, + projectId: secret.subject, + }); + } else if (secret?.type === 'local' && secret.apiKey) { + await saveProviderKeyToOpenClaw(ock, secret.apiKey); + } + + const meta = getProviderConfig(config.type); + const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; + + if (!api) { + return; + } + + await syncProviderConfigToOpenClaw(ock, config.model, { + baseUrl: config.baseUrl || meta?.baseUrl, + api, + apiKeyEnv: meta?.apiKeyEnv, + headers: meta?.headers, + }); + + if (config.type === 'custom' || config.type === 'ollama') { + const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id); + if (resolvedKey && config.baseUrl) { + const modelId = config.model; + await updateAgentModelProvider(ock, { + baseUrl: config.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: resolvedKey, + }); + } + } + + scheduleGatewayRestart( + gatewayManager, + `Scheduling Gateway restart after saving provider "${ock}" config`, + ); +} + +export async function syncUpdatedProviderToRuntime( + config: ProviderConfig, + apiKey: string | undefined, + gatewayManager?: GatewayManager, +): Promise { + const ock = await resolveRuntimeProviderKey(config); + const fallbackModels = await getProviderFallbackModelRefs(config); + const meta = getProviderConfig(config.type); + const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api; + const secret = await getProviderSecret(config.id); + + if (!api) { + return; + } + + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await saveProviderKeyToOpenClaw(ock, trimmedKey); + } + } else if (secret?.type === 'api_key') { + await saveProviderKeyToOpenClaw(ock, secret.apiKey); + } else if (secret?.type === 'oauth') { + await saveOAuthTokenToOpenClaw(ock, { + access: secret.accessToken, + refresh: secret.refreshToken, + expires: secret.expiresAt, + email: secret.email, + projectId: secret.subject, + }); + } else if (secret?.type === 'local' && secret.apiKey) { + await saveProviderKeyToOpenClaw(ock, secret.apiKey); + } + + await syncProviderConfigToOpenClaw(ock, config.model, { + baseUrl: config.baseUrl || meta?.baseUrl, + api, + apiKeyEnv: meta?.apiKeyEnv, + headers: meta?.headers, + }); + + if (config.type === 'custom' || config.type === 'ollama') { + const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id); + if (resolvedKey && config.baseUrl) { + const modelId = config.model; + await updateAgentModelProvider(ock, { + baseUrl: config.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: resolvedKey, + }); + } + } + + const defaultProviderId = await getDefaultProvider(); + if (defaultProviderId === config.id) { + const modelOverride = config.model ? `${ock}/${config.model}` : undefined; + if (config.type !== 'custom' && config.type !== 'ollama') { + await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); + } else { + await setOpenClawDefaultModelWithOverride(ock, modelOverride, { + baseUrl: config.baseUrl, + api: 'openai-completions', + }, fallbackModels); + } + } + + scheduleGatewayRestart( + gatewayManager, + `Scheduling Gateway restart after updating provider "${ock}" config`, + ); +} + +export async function syncDeletedProviderToRuntime( + provider: ProviderConfig | null, + providerId: string, + gatewayManager?: GatewayManager, + runtimeProviderKey?: string, +): Promise { + if (!provider?.type) { + return; + } + + const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId }); + await removeProviderFromOpenClaw(ock); + + scheduleGatewayRestart( + gatewayManager, + `Scheduling Gateway restart after deleting provider "${ock}"`, + ); +} + +export async function syncDeletedProviderApiKeyToRuntime( + provider: ProviderConfig | null, + providerId: string, + runtimeProviderKey?: string, +): Promise { + if (!provider?.type) { + return; + } + + const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId }); + await removeProviderFromOpenClaw(ock); +} + +export async function syncDefaultProviderToRuntime( + providerId: string, + gatewayManager?: GatewayManager, +): Promise { + const provider = await getProvider(providerId); + if (!provider) { + return; + } + + const ock = await resolveRuntimeProviderKey(provider); + const providerKey = await getApiKey(providerId); + const fallbackModels = await getProviderFallbackModelRefs(provider); + const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn']; + const isGoogleOAuthProvider = await isGoogleBrowserOAuthProvider(provider); + const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || isGoogleOAuthProvider; + + if (!isOAuthProvider) { + const modelOverride = provider.model + ? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`) + : undefined; + + if (provider.type === 'custom' || provider.type === 'ollama') { + await setOpenClawDefaultModelWithOverride(ock, modelOverride, { + baseUrl: provider.baseUrl, + api: 'openai-completions', + }, fallbackModels); + } else { + await setOpenClawDefaultModel(ock, modelOverride, fallbackModels); + } + + if (providerKey) { + await saveProviderKeyToOpenClaw(ock, providerKey); + } + } else { + if (isGoogleOAuthProvider) { + const secret = await getProviderSecret(provider.id); + if (secret?.type === 'oauth') { + await saveOAuthTokenToOpenClaw(GOOGLE_OAUTH_RUNTIME_PROVIDER, { + access: secret.accessToken, + refresh: secret.refreshToken, + expires: secret.expiresAt, + email: secret.email, + projectId: secret.subject, + }); + } + + const modelOverride = provider.model + ? (provider.model.startsWith(`${GOOGLE_OAUTH_RUNTIME_PROVIDER}/`) + ? provider.model + : `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/${provider.model}`) + : GOOGLE_OAUTH_DEFAULT_MODEL_REF; + + await setOpenClawDefaultModel(GOOGLE_OAUTH_RUNTIME_PROVIDER, modelOverride, fallbackModels); + logger.info(`Configured openclaw.json for Google browser OAuth provider "${provider.id}"`); + scheduleGatewayRestart( + gatewayManager, + `Scheduling Gateway restart after provider switch to "${GOOGLE_OAUTH_RUNTIME_PROVIDER}"`, + ); + return; + } + + const defaultBaseUrl = provider.type === 'minimax-portal' + ? 'https://api.minimax.io/anthropic' + : (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1'); + const api: 'anthropic-messages' | 'openai-completions' = + (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') + ? 'anthropic-messages' + : 'openai-completions'; + + let baseUrl = provider.baseUrl || defaultBaseUrl; + if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) { + baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; + } + + const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') + ? 'minimax-portal' + : provider.type; + + await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), { + baseUrl, + api, + authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, + apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + }, fallbackModels); + + logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`); + + try { + const defaultModelId = provider.model?.split('/').pop(); + await updateAgentModelProvider(targetProviderKey, { + baseUrl, + api, + authHeader: targetProviderKey === 'minimax-portal' ? true : undefined, + apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth', + models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [], + }); + } catch (err) { + logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err); + } + } + + if ( + (provider.type === 'custom' || provider.type === 'ollama') && + providerKey && + provider.baseUrl + ) { + const modelId = provider.model; + await updateAgentModelProvider(ock, { + baseUrl: provider.baseUrl, + api: 'openai-completions', + models: modelId ? [{ id: modelId, name: modelId }] : [], + apiKey: providerKey, + }); + } + + scheduleGatewayRestart( + gatewayManager, + `Scheduling Gateway restart after provider switch to "${ock}"`, + { onlyIfRunning: true }, + ); +} diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts new file mode 100644 index 000000000..a9c7361d6 --- /dev/null +++ b/electron/services/providers/provider-service.ts @@ -0,0 +1,168 @@ +import { + PROVIDER_DEFINITIONS, + getProviderDefinition, +} from '../../shared/providers/registry'; +import type { + ProviderAccount, + ProviderConfig, + ProviderDefinition, +} from '../../shared/providers/types'; +import { ensureProviderStoreMigrated } from './provider-migration'; +import { + getDefaultProviderAccountId, + getProviderAccount, + listProviderAccounts, + providerAccountToConfig, + providerConfigToAccount, + saveProviderAccount, + setDefaultProviderAccount, +} from './provider-store'; +import { + deleteApiKey, + deleteProvider, + getAllProviders, + getAllProvidersWithKeyInfo, + getApiKey, + getDefaultProvider, + getProvider, + hasApiKey, + saveProvider, + setDefaultProvider, + storeApiKey, +} from '../../utils/secure-storage'; +import type { ProviderWithKeyInfo } from '../../shared/providers/types'; + +export class ProviderService { + async listVendors(): Promise { + return PROVIDER_DEFINITIONS; + } + + async listAccounts(): Promise { + await ensureProviderStoreMigrated(); + return listProviderAccounts(); + } + + async getAccount(accountId: string): Promise { + await ensureProviderStoreMigrated(); + return getProviderAccount(accountId); + } + + async getDefaultAccountId(): Promise { + await ensureProviderStoreMigrated(); + return (await getDefaultProvider()) ?? getDefaultProviderAccountId(); + } + + async createAccount(account: ProviderAccount, apiKey?: string): Promise { + await ensureProviderStoreMigrated(); + await saveProvider(providerAccountToConfig(account)); + await saveProviderAccount(account); + if (apiKey !== undefined && apiKey.trim()) { + await storeApiKey(account.id, apiKey.trim()); + } + return (await getProviderAccount(account.id)) ?? account; + } + + async updateAccount( + accountId: string, + patch: Partial, + apiKey?: string, + ): Promise { + await ensureProviderStoreMigrated(); + const existing = await getProviderAccount(accountId); + if (!existing) { + throw new Error('Provider account not found'); + } + + const nextAccount: ProviderAccount = { + ...existing, + ...patch, + id: accountId, + updatedAt: patch.updatedAt ?? new Date().toISOString(), + }; + + await saveProvider(providerAccountToConfig(nextAccount)); + await saveProviderAccount(nextAccount); + if (apiKey !== undefined) { + const trimmedKey = apiKey.trim(); + if (trimmedKey) { + await storeApiKey(accountId, trimmedKey); + } else { + await deleteApiKey(accountId); + } + } + + return (await getProviderAccount(accountId)) ?? nextAccount; + } + + async deleteAccount(accountId: string): Promise { + await ensureProviderStoreMigrated(); + return deleteProvider(accountId); + } + + async syncLegacyProvider(config: ProviderConfig, options?: { isDefault?: boolean }): Promise { + await ensureProviderStoreMigrated(); + const account = providerConfigToAccount(config, options); + await saveProviderAccount(account); + return account; + } + + async listLegacyProviders(): Promise { + return getAllProviders(); + } + + async listLegacyProvidersWithKeyInfo(): Promise { + return getAllProvidersWithKeyInfo(); + } + + async getLegacyProvider(providerId: string): Promise { + return getProvider(providerId); + } + + async saveLegacyProvider(config: ProviderConfig): Promise { + await saveProvider(config); + } + + async deleteLegacyProvider(providerId: string): Promise { + return deleteProvider(providerId); + } + + async setDefaultLegacyProvider(providerId: string): Promise { + await setDefaultProvider(providerId); + } + + async getDefaultLegacyProvider(): Promise { + return getDefaultProvider(); + } + + async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise { + return storeApiKey(providerId, apiKey); + } + + async getLegacyProviderApiKey(providerId: string): Promise { + return getApiKey(providerId); + } + + async deleteLegacyProviderApiKey(providerId: string): Promise { + return deleteApiKey(providerId); + } + + async hasLegacyProviderApiKey(providerId: string): Promise { + return hasApiKey(providerId); + } + + async setDefaultAccount(accountId: string): Promise { + await ensureProviderStoreMigrated(); + await setDefaultProviderAccount(accountId); + await setDefaultProvider(accountId); + } + + getVendorDefinition(vendorId: string): ProviderDefinition | undefined { + return getProviderDefinition(vendorId); + } +} + +const providerService = new ProviderService(); + +export function getProviderService(): ProviderService { + return providerService; +} diff --git a/electron/services/providers/provider-store.ts b/electron/services/providers/provider-store.ts new file mode 100644 index 000000000..287f58c09 --- /dev/null +++ b/electron/services/providers/provider-store.ts @@ -0,0 +1,103 @@ +import type { ProviderAccount, ProviderConfig, ProviderType } from '../../shared/providers/types'; +import { getProviderDefinition } from '../../shared/providers/registry'; +import { getClawXProviderStore } from './store-instance'; + +const PROVIDER_STORE_SCHEMA_VERSION = 1; + +function inferAuthMode(type: ProviderType): ProviderAccount['authMode'] { + if (type === 'ollama') { + return 'local'; + } + + const definition = getProviderDefinition(type); + if (definition?.defaultAuthMode) { + return definition.defaultAuthMode; + } + + return 'api_key'; +} + +export function providerConfigToAccount( + config: ProviderConfig, + options?: { isDefault?: boolean }, +): ProviderAccount { + return { + id: config.id, + vendorId: config.type, + label: config.name, + authMode: inferAuthMode(config.type), + baseUrl: config.baseUrl, + apiProtocol: config.type === 'custom' || config.type === 'ollama' + ? 'openai-completions' + : getProviderDefinition(config.type)?.providerConfig?.api, + model: config.model, + fallbackModels: config.fallbackModels, + fallbackAccountIds: config.fallbackProviderIds, + enabled: config.enabled, + isDefault: options?.isDefault ?? false, + createdAt: config.createdAt, + updatedAt: config.updatedAt, + }; +} + +export function providerAccountToConfig(account: ProviderAccount): ProviderConfig { + return { + id: account.id, + name: account.label, + type: account.vendorId, + baseUrl: account.baseUrl, + model: account.model, + fallbackModels: account.fallbackModels, + fallbackProviderIds: account.fallbackAccountIds, + enabled: account.enabled, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + }; +} + +export async function listProviderAccounts(): Promise { + const store = await getClawXProviderStore(); + const accounts = store.get('providerAccounts') as Record | undefined; + return Object.values(accounts ?? {}); +} + +export async function getProviderAccount(accountId: string): Promise { + const store = await getClawXProviderStore(); + const accounts = store.get('providerAccounts') as Record | undefined; + return accounts?.[accountId] ?? null; +} + +export async function saveProviderAccount(account: ProviderAccount): Promise { + const store = await getClawXProviderStore(); + const accounts = (store.get('providerAccounts') ?? {}) as Record; + accounts[account.id] = account; + store.set('providerAccounts', accounts); + store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION); +} + +export async function deleteProviderAccount(accountId: string): Promise { + const store = await getClawXProviderStore(); + const accounts = (store.get('providerAccounts') ?? {}) as Record; + delete accounts[accountId]; + store.set('providerAccounts', accounts); + + if (store.get('defaultProviderAccountId') === accountId) { + store.delete('defaultProviderAccountId'); + } +} + +export async function setDefaultProviderAccount(accountId: string): Promise { + const store = await getClawXProviderStore(); + store.set('defaultProviderAccountId', accountId); + + const accounts = (store.get('providerAccounts') ?? {}) as Record; + for (const account of Object.values(accounts)) { + account.isDefault = account.id === accountId; + } + store.set('providerAccounts', accounts); +} + +export async function getDefaultProviderAccountId(): Promise { + const store = await getClawXProviderStore(); + return store.get('defaultProviderAccountId') as string | undefined; +} diff --git a/electron/services/providers/provider-validation.ts b/electron/services/providers/provider-validation.ts new file mode 100644 index 000000000..ce298ae28 --- /dev/null +++ b/electron/services/providers/provider-validation.ts @@ -0,0 +1,238 @@ +import { proxyAwareFetch } from '../../utils/proxy-fetch'; + +type ValidationProfile = + | 'openai-compatible' + | 'google-query-key' + | 'anthropic-header' + | 'openrouter' + | 'none'; + +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): Record { + 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, +): 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 'openrouter': + return 'openrouter'; + case 'ollama': + return 'none'; + default: + return 'openai-compatible'; + } +} + +async function performProviderValidationRequest( + providerLabel: string, + url: string, + headers: Record, +): Promise<{ valid: boolean; error?: string }> { + try { + logValidationRequest(providerLabel, 'GET', url, headers); + const response = await proxyAwareFetch(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)}`, + }; + } +} + +function classifyAuthResponse( + status: number, + data: unknown, +): { valid: boolean; error?: string } { + if (status >= 200 && status < 300) return { valid: true }; + if (status === 429) return { valid: true }; + if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' }; + + 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}` }; + const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl); + const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers); + + 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; +} + +async function performChatCompletionsProbe( + providerLabel: string, + url: string, + headers: Record, +): Promise<{ valid: boolean; error?: string }> { + try { + logValidationRequest(providerLabel, 'POST', url, headers); + const response = await proxyAwareFetch(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(() => ({})); + + if (response.status === 401 || response.status === 403) { + return { valid: false, error: 'Invalid API key' }; + } + 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 base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta'); + 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); +} + +async function validateOpenRouterKey( + providerType: string, + apiKey: string, +): Promise<{ valid: boolean; error?: string }> { + const url = 'https://openrouter.ai/api/v1/auth/key'; + const headers = { Authorization: `Bearer ${apiKey}` }; + return await performProviderValidationRequest(providerType, url, headers); +} + +export 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); + case 'openrouter': + return await validateOpenRouterKey(providerType, trimmedKey); + 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 }; + } +} diff --git a/electron/services/providers/store-instance.ts b/electron/services/providers/store-instance.ts new file mode 100644 index 000000000..1da129b08 --- /dev/null +++ b/electron/services/providers/store-instance.ts @@ -0,0 +1,23 @@ +// Lazy-load electron-store (ESM module) from the main process only. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let providerStore: any = null; + +export async function getClawXProviderStore() { + if (!providerStore) { + const Store = (await import('electron-store')).default; + providerStore = new Store({ + name: 'clawx-providers', + defaults: { + schemaVersion: 0, + providers: {} as Record, + providerAccounts: {} as Record, + apiKeys: {} as Record, + providerSecrets: {} as Record, + defaultProvider: null as string | null, + defaultProviderAccountId: null as string | null, + }, + }); + } + + return providerStore; +} diff --git a/electron/services/secrets/secret-store.ts b/electron/services/secrets/secret-store.ts new file mode 100644 index 000000000..ca9afecdb --- /dev/null +++ b/electron/services/secrets/secret-store.ts @@ -0,0 +1,82 @@ +import type { ProviderSecret } from '../../shared/providers/types'; +import { getClawXProviderStore } from '../providers/store-instance'; + +export interface SecretStore { + get(accountId: string): Promise; + set(secret: ProviderSecret): Promise; + delete(accountId: string): Promise; +} + +export class ElectronStoreSecretStore implements SecretStore { + async get(accountId: string): Promise { + const store = await getClawXProviderStore(); + const secrets = (store.get('providerSecrets') ?? {}) as Record; + const secret = secrets[accountId]; + if (secret) { + return secret; + } + + const apiKeys = (store.get('apiKeys') ?? {}) as Record; + const apiKey = apiKeys[accountId]; + if (!apiKey) { + return null; + } + + return { + type: 'api_key', + accountId, + apiKey, + }; + } + + async set(secret: ProviderSecret): Promise { + const store = await getClawXProviderStore(); + const secrets = (store.get('providerSecrets') ?? {}) as Record; + secrets[secret.accountId] = secret; + store.set('providerSecrets', secrets); + + // Keep legacy apiKeys in sync until the rest of the app moves to account-based secrets. + const apiKeys = (store.get('apiKeys') ?? {}) as Record; + if (secret.type === 'api_key') { + apiKeys[secret.accountId] = secret.apiKey; + } else if (secret.type === 'local') { + if (secret.apiKey) { + apiKeys[secret.accountId] = secret.apiKey; + } else { + delete apiKeys[secret.accountId]; + } + } else { + delete apiKeys[secret.accountId]; + } + store.set('apiKeys', apiKeys); + } + + async delete(accountId: string): Promise { + const store = await getClawXProviderStore(); + const secrets = (store.get('providerSecrets') ?? {}) as Record; + delete secrets[accountId]; + store.set('providerSecrets', secrets); + + const apiKeys = (store.get('apiKeys') ?? {}) as Record; + delete apiKeys[accountId]; + store.set('apiKeys', apiKeys); + } +} + +const secretStore = new ElectronStoreSecretStore(); + +export function getSecretStore(): SecretStore { + return secretStore; +} + +export async function getProviderSecret(accountId: string): Promise { + return getSecretStore().get(accountId); +} + +export async function setProviderSecret(secret: ProviderSecret): Promise { + await getSecretStore().set(secret); +} + +export async function deleteProviderSecret(accountId: string): Promise { + await getSecretStore().delete(accountId); +} diff --git a/electron/shared/providers/registry.ts b/electron/shared/providers/registry.ts new file mode 100644 index 000000000..d02b00cad --- /dev/null +++ b/electron/shared/providers/registry.ts @@ -0,0 +1,293 @@ +import type { + ProviderBackendConfig, + ProviderDefinition, + ProviderType, + ProviderTypeInfo, +} from './types'; + +export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ + { + id: 'anthropic', + name: 'Anthropic', + icon: '🤖', + placeholder: 'sk-ant-api03-...', + model: 'Claude', + requiresApiKey: true, + category: 'official', + envVar: 'ANTHROPIC_API_KEY', + defaultModelId: 'claude-opus-4-6', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + }, + { + id: 'openai', + name: 'OpenAI', + icon: '💚', + placeholder: 'sk-proj-...', + model: 'GPT', + requiresApiKey: true, + category: 'official', + envVar: 'OPENAI_API_KEY', + defaultModelId: 'gpt-5.2', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://api.openai.com/v1', + api: 'openai-responses', + apiKeyEnv: 'OPENAI_API_KEY', + }, + }, + { + id: 'google', + name: 'Google', + icon: '🔷', + placeholder: 'AIza...', + model: 'Gemini', + requiresApiKey: true, + category: 'official', + envVar: 'GEMINI_API_KEY', + defaultModelId: 'gemini-3.1-pro-preview', + isOAuth: true, + supportsApiKey: true, + supportedAuthModes: ['api_key', 'oauth_browser'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + }, + { + id: 'openrouter', + name: 'OpenRouter', + icon: '🌐', + placeholder: 'sk-or-v1-...', + model: 'Multi-Model', + requiresApiKey: true, + showModelId: true, + modelIdPlaceholder: 'anthropic/claude-opus-4.6', + defaultModelId: 'anthropic/claude-opus-4.6', + category: 'compatible', + envVar: 'OPENROUTER_API_KEY', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://openrouter.ai/api/v1', + api: 'openai-completions', + apiKeyEnv: 'OPENROUTER_API_KEY', + headers: { + 'HTTP-Referer': 'https://claw-x.com', + 'X-Title': 'ClawX', + }, + }, + }, + { + id: 'ark', + name: 'ByteDance Ark', + icon: 'A', + placeholder: 'your-ark-api-key', + model: 'Doubao', + requiresApiKey: true, + defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + showBaseUrl: true, + showModelId: true, + modelIdPlaceholder: 'ep-20260228000000-xxxxx', + category: 'official', + envVar: 'ARK_API_KEY', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + api: 'openai-completions', + apiKeyEnv: 'ARK_API_KEY', + }, + }, + { + id: 'moonshot', + name: 'Moonshot (CN)', + icon: '🌙', + placeholder: 'sk-...', + model: 'Kimi', + requiresApiKey: true, + defaultBaseUrl: 'https://api.moonshot.cn/v1', + defaultModelId: 'kimi-k2.5', + category: 'official', + envVar: 'MOONSHOT_API_KEY', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://api.moonshot.cn/v1', + api: 'openai-completions', + apiKeyEnv: 'MOONSHOT_API_KEY', + models: [ + { + id: 'kimi-k2.5', + name: 'Kimi K2.5', + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 256000, + maxTokens: 8192, + }, + ], + }, + }, + { + id: 'siliconflow', + name: 'SiliconFlow (CN)', + icon: '🌊', + placeholder: 'sk-...', + model: 'Multi-Model', + requiresApiKey: true, + defaultBaseUrl: 'https://api.siliconflow.cn/v1', + showModelId: true, + showModelIdInDevModeOnly: true, + modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', + defaultModelId: 'deepseek-ai/DeepSeek-V3', + category: 'compatible', + envVar: 'SILICONFLOW_API_KEY', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://api.siliconflow.cn/v1', + api: 'openai-completions', + apiKeyEnv: 'SILICONFLOW_API_KEY', + }, + }, + { + id: 'minimax-portal', + name: 'MiniMax (Global)', + icon: '☁️', + placeholder: 'sk-...', + model: 'MiniMax', + requiresApiKey: false, + isOAuth: true, + supportsApiKey: true, + defaultModelId: 'MiniMax-M2.5', + apiKeyUrl: 'https://intl.minimaxi.com/', + category: 'official', + envVar: 'MINIMAX_API_KEY', + supportedAuthModes: ['oauth_device', 'api_key'], + defaultAuthMode: 'oauth_device', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://api.minimax.io/anthropic', + api: 'anthropic-messages', + apiKeyEnv: 'MINIMAX_API_KEY', + }, + }, + { + id: 'minimax-portal-cn', + name: 'MiniMax (CN)', + icon: '☁️', + placeholder: 'sk-...', + model: 'MiniMax', + requiresApiKey: false, + isOAuth: true, + supportsApiKey: true, + defaultModelId: 'MiniMax-M2.5', + apiKeyUrl: 'https://platform.minimaxi.com/', + category: 'official', + envVar: 'MINIMAX_CN_API_KEY', + supportedAuthModes: ['oauth_device', 'api_key'], + defaultAuthMode: 'oauth_device', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://api.minimaxi.com/anthropic', + api: 'anthropic-messages', + apiKeyEnv: 'MINIMAX_CN_API_KEY', + }, + }, + { + id: 'qwen-portal', + name: 'Qwen', + icon: '☁️', + placeholder: 'sk-...', + model: 'Qwen', + requiresApiKey: false, + isOAuth: true, + defaultModelId: 'coder-model', + category: 'official', + envVar: 'QWEN_API_KEY', + supportedAuthModes: ['oauth_device'], + defaultAuthMode: 'oauth_device', + supportsMultipleAccounts: true, + providerConfig: { + baseUrl: 'https://portal.qwen.ai/v1', + api: 'openai-completions', + apiKeyEnv: 'QWEN_API_KEY', + }, + }, + { + id: 'ollama', + name: 'Ollama', + icon: '🦙', + placeholder: 'Not required', + requiresApiKey: false, + defaultBaseUrl: 'http://localhost:11434/v1', + showBaseUrl: true, + showModelId: true, + modelIdPlaceholder: 'qwen3:latest', + category: 'local', + supportedAuthModes: ['local'], + defaultAuthMode: 'local', + supportsMultipleAccounts: true, + }, + { + id: 'custom', + name: 'Custom', + icon: '⚙️', + placeholder: 'API key...', + requiresApiKey: true, + showBaseUrl: true, + showModelId: true, + modelIdPlaceholder: 'your-provider/model-id', + category: 'custom', + envVar: 'CUSTOM_API_KEY', + supportedAuthModes: ['api_key'], + defaultAuthMode: 'api_key', + supportsMultipleAccounts: true, + }, +]; + +const PROVIDER_DEFINITION_MAP = new Map( + PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]), +); + +export function getProviderDefinition( + type: ProviderType | string, +): ProviderDefinition | undefined { + return PROVIDER_DEFINITION_MAP.get(type as ProviderType); +} + +export function getProviderTypeInfo( + type: ProviderType, +): ProviderTypeInfo | undefined { + return getProviderDefinition(type); +} + +export function getProviderEnvVar(type: string): string | undefined { + return getProviderDefinition(type)?.envVar; +} + +export function getProviderDefaultModel(type: string): string | undefined { + return getProviderDefinition(type)?.defaultModelId; +} + +export function getProviderBackendConfig( + type: string, +): ProviderBackendConfig | undefined { + return getProviderDefinition(type)?.providerConfig; +} + +export function getProviderUiInfoList(): ProviderTypeInfo[] { + return PROVIDER_DEFINITIONS; +} + +export function getKeyableProviderTypes(): string[] { + return PROVIDER_DEFINITIONS.filter((definition) => definition.envVar).map( + (definition) => definition.id, + ); +} diff --git a/electron/shared/providers/types.ts b/electron/shared/providers/types.ts new file mode 100644 index 000000000..2cda0a972 --- /dev/null +++ b/electron/shared/providers/types.ts @@ -0,0 +1,169 @@ +export const PROVIDER_TYPES = [ + 'anthropic', + 'openai', + 'google', + 'openrouter', + 'ark', + 'moonshot', + 'siliconflow', + 'minimax-portal', + 'minimax-portal-cn', + 'qwen-portal', + 'ollama', + 'custom', +] as const; + +export const BUILTIN_PROVIDER_TYPES = [ + 'anthropic', + 'openai', + 'google', + 'openrouter', + 'ark', + 'moonshot', + 'siliconflow', + 'minimax-portal', + 'minimax-portal-cn', + 'qwen-portal', + 'ollama', +] as const; + +export type ProviderType = (typeof PROVIDER_TYPES)[number]; +export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number]; + +export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local'; + +export type ProviderProtocol = + | 'openai-completions' + | 'openai-responses' + | 'anthropic-messages'; + +export type ProviderAuthMode = + | 'api_key' + | 'oauth_device' + | 'oauth_browser' + | 'local'; + +export type ProviderVendorCategory = + | 'official' + | 'compatible' + | 'local' + | 'custom'; + +export interface ProviderConfig { + id: string; + name: string; + type: ProviderType; + baseUrl?: string; + model?: string; + fallbackModels?: string[]; + fallbackProviderIds?: string[]; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ProviderWithKeyInfo extends ProviderConfig { + hasKey: boolean; + keyMasked: string | null; +} + +export interface ProviderTypeInfo { + id: ProviderType; + name: string; + icon: string; + placeholder: string; + model?: string; + requiresApiKey: boolean; + defaultBaseUrl?: string; + showBaseUrl?: boolean; + showModelId?: boolean; + showModelIdInDevModeOnly?: boolean; + modelIdPlaceholder?: string; + defaultModelId?: string; + isOAuth?: boolean; + supportsApiKey?: boolean; + apiKeyUrl?: string; +} + +export interface ProviderModelEntry extends Record { + id: string; + name: string; +} + +export interface ProviderBackendConfig { + baseUrl: string; + api: ProviderProtocol; + apiKeyEnv: string; + models?: ProviderModelEntry[]; + headers?: Record; +} + +export interface ProviderDefinition extends ProviderTypeInfo { + category: ProviderVendorCategory; + envVar?: string; + providerConfig?: ProviderBackendConfig; + supportedAuthModes: ProviderAuthMode[]; + defaultAuthMode: ProviderAuthMode; + supportsMultipleAccounts: boolean; +} + +export interface ProviderAccount { + id: string; + vendorId: ProviderType; + label: string; + authMode: ProviderAuthMode; + baseUrl?: string; + apiProtocol?: ProviderProtocol; + model?: string; + fallbackModels?: string[]; + fallbackAccountIds?: string[]; + enabled: boolean; + isDefault: boolean; + metadata?: { + region?: string; + email?: string; + resourceUrl?: string; + customModels?: string[]; + }; + createdAt: string; + updatedAt: string; +} + +export type ProviderSecret = + | { + type: 'api_key'; + accountId: string; + apiKey: string; + } + | { + type: 'oauth'; + accountId: string; + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes?: string[]; + email?: string; + subject?: string; + } + | { + type: 'local'; + accountId: string; + apiKey?: string; + }; + +export interface ModelSummary { + id: string; + name: string; + vendorId: string; + accountId?: string; + supportsVision?: boolean; + supportsReasoning?: boolean; + contextWindow?: number; + pricing?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + }; + source: 'builtin' | 'remote' | 'gateway' | 'custom'; +} diff --git a/electron/utils/browser-oauth.ts b/electron/utils/browser-oauth.ts new file mode 100644 index 000000000..822037f91 --- /dev/null +++ b/electron/utils/browser-oauth.ts @@ -0,0 +1,161 @@ +import { EventEmitter } from 'events'; +import { BrowserWindow, shell } from 'electron'; +import { logger } from './logger'; +import { loginGeminiCliOAuth, type GeminiCliOAuthCredentials } from './gemini-cli-oauth'; +import { getProviderService } from '../services/providers/provider-service'; +import { getSecretStore } from '../services/secrets/secret-store'; +import { saveOAuthTokenToOpenClaw } from './openclaw-auth'; + +export type BrowserOAuthProviderType = 'google'; + +const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli'; +const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview'; + +class BrowserOAuthManager extends EventEmitter { + private activeProvider: BrowserOAuthProviderType | null = null; + private activeAccountId: string | null = null; + private activeLabel: string | null = null; + private active = false; + private mainWindow: BrowserWindow | null = null; + + setWindow(window: BrowserWindow) { + this.mainWindow = window; + } + + async startFlow( + provider: BrowserOAuthProviderType, + options?: { accountId?: string; label?: string }, + ): Promise { + if (this.active) { + await this.stopFlow(); + } + + this.active = true; + this.activeProvider = provider; + this.activeAccountId = options?.accountId || provider; + this.activeLabel = options?.label || null; + this.emit('oauth:start', { provider, accountId: this.activeAccountId }); + + try { + if (provider !== 'google') { + throw new Error(`Unsupported browser OAuth provider type: ${provider}`); + } + + const token = await loginGeminiCliOAuth({ + isRemote: false, + openUrl: async (url) => { + await shell.openExternal(url); + }, + log: (message) => logger.info(`[BrowserOAuth] ${message}`), + note: async (message, title) => { + logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`); + }, + prompt: async () => { + throw new Error('Manual browser OAuth fallback is not implemented in ClawX yet.'); + }, + progress: { + update: (message) => logger.info(`[BrowserOAuth] ${message}`), + stop: (message) => { + if (message) { + logger.info(`[BrowserOAuth] ${message}`); + } + }, + }, + }); + + await this.onSuccess(provider, token); + return true; + } catch (error) { + if (!this.active) { + return false; + } + logger.error(`[BrowserOAuth] Flow error for ${provider}:`, error); + this.emitError(error instanceof Error ? error.message : String(error)); + this.active = false; + this.activeProvider = null; + this.activeAccountId = null; + this.activeLabel = null; + return false; + } + } + + async stopFlow(): Promise { + this.active = false; + this.activeProvider = null; + this.activeAccountId = null; + this.activeLabel = null; + logger.info('[BrowserOAuth] Flow explicitly stopped'); + } + + private async onSuccess( + providerType: BrowserOAuthProviderType, + token: GeminiCliOAuthCredentials, + ) { + const accountId = this.activeAccountId || providerType; + const accountLabel = this.activeLabel; + this.active = false; + this.activeProvider = null; + this.activeAccountId = null; + this.activeLabel = null; + logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`); + + const providerService = getProviderService(); + const existing = await providerService.getAccount(accountId); + const nextAccount = await providerService.createAccount({ + id: accountId, + vendorId: providerType, + label: accountLabel || existing?.label || 'Google Gemini', + authMode: 'oauth_browser', + baseUrl: existing?.baseUrl, + apiProtocol: existing?.apiProtocol, + model: existing?.model || GOOGLE_OAUTH_DEFAULT_MODEL, + fallbackModels: existing?.fallbackModels, + fallbackAccountIds: existing?.fallbackAccountIds, + enabled: existing?.enabled ?? true, + isDefault: existing?.isDefault ?? false, + metadata: { + ...existing?.metadata, + email: token.email, + resourceUrl: GOOGLE_RUNTIME_PROVIDER_ID, + }, + createdAt: existing?.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + await getSecretStore().set({ + type: 'oauth', + accountId, + accessToken: token.access, + refreshToken: token.refresh, + expiresAt: token.expires, + email: token.email, + subject: token.projectId, + }); + + await saveOAuthTokenToOpenClaw(GOOGLE_RUNTIME_PROVIDER_ID, { + access: token.access, + refresh: token.refresh, + expires: token.expires, + email: token.email, + projectId: token.projectId, + }); + + this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id }); + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send('oauth:success', { + provider: providerType, + accountId: nextAccount.id, + success: true, + }); + } + } + + private emitError(message: string) { + this.emit('oauth:error', { message }); + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send('oauth:error', { message }); + } + } +} + +export const browserOAuthManager = new BrowserOAuthManager(); diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index f9a335f07..b1678e88a 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -111,21 +111,6 @@ export async function saveChannelConfig( } } - // DingTalk is a channel plugin; make sure it's explicitly allowed. - // Newer OpenClaw versions may not load non-bundled plugins when allowlist is empty. - if (channelType === 'dingtalk') { - if (!currentConfig.plugins) { - currentConfig.plugins = {}; - } - currentConfig.plugins.enabled = true; - const allow = Array.isArray(currentConfig.plugins.allow) - ? currentConfig.plugins.allow as string[] - : []; - if (!allow.includes('dingtalk')) { - currentConfig.plugins.allow = [...allow, 'dingtalk']; - } - } - // Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels if (PLUGIN_CHANNELS.includes(channelType)) { if (!currentConfig.plugins) { diff --git a/electron/utils/config.ts b/electron/utils/config.ts index 0e837c0ce..d221c972e 100644 --- a/electron/utils/config.ts +++ b/electron/utils/config.ts @@ -12,6 +12,9 @@ export const PORTS = { /** ClawX GUI production port (for reference) */ CLAWX_GUI: 23333, + + /** Local host API server port */ + CLAWX_HOST_API: 3210, /** OpenClaw Gateway port */ OPENCLAW_GATEWAY: 18789, diff --git a/electron/utils/device-oauth.ts b/electron/utils/device-oauth.ts index 213be03ae..4589f2fc7 100644 --- a/electron/utils/device-oauth.ts +++ b/electron/utils/device-oauth.ts @@ -42,6 +42,8 @@ export type { MiniMaxRegion }; class DeviceOAuthManager extends EventEmitter { private activeProvider: OAuthProviderType | null = null; + private activeAccountId: string | null = null; + private activeLabel: string | null = null; private active: boolean = false; private mainWindow: BrowserWindow | null = null; @@ -49,14 +51,20 @@ class DeviceOAuthManager extends EventEmitter { this.mainWindow = window; } - async startFlow(provider: OAuthProviderType, region: MiniMaxRegion = 'global'): Promise { + async startFlow( + provider: OAuthProviderType, + region: MiniMaxRegion = 'global', + options?: { accountId?: string; label?: string }, + ): Promise { if (this.active) { await this.stopFlow(); } this.active = true; - this.emit('oauth:start', { provider: provider }); + this.emit('oauth:start', { provider, accountId: options?.accountId || provider }); this.activeProvider = provider; + this.activeAccountId = options?.accountId || provider; + this.activeLabel = options?.label || null; try { if (provider === 'minimax-portal' || provider === 'minimax-portal-cn') { @@ -77,6 +85,8 @@ class DeviceOAuthManager extends EventEmitter { this.emitError(error instanceof Error ? error.message : String(error)); this.active = false; this.activeProvider = null; + this.activeAccountId = null; + this.activeLabel = null; return false; } } @@ -84,6 +94,8 @@ class DeviceOAuthManager extends EventEmitter { async stopFlow(): Promise { this.active = false; this.activeProvider = null; + this.activeAccountId = null; + this.activeLabel = null; logger.info('[DeviceOAuth] Flow explicitly stopped'); } @@ -194,8 +206,12 @@ class DeviceOAuthManager extends EventEmitter { api: 'anthropic-messages' | 'openai-completions'; region?: MiniMaxRegion; }) { + const accountId = this.activeAccountId || providerType; + const accountLabel = this.activeLabel; this.active = false; this.activeProvider = null; + this.activeAccountId = null; + this.activeLabel = null; logger.info(`[DeviceOAuth] Successfully completed OAuth for ${providerType}`); // 1. Write OAuth token to OpenClaw's auth-profiles.json in native OAuth format. @@ -254,15 +270,15 @@ class DeviceOAuthManager extends EventEmitter { } // 3. Save provider record in ClawX's own store so UI shows it as configured - const existing = await getProvider(providerType); + const existing = await getProvider(accountId); const nameMap: Record = { 'minimax-portal': 'MiniMax (Global)', 'minimax-portal-cn': 'MiniMax (CN)', 'qwen-portal': 'Qwen', }; const providerConfig: ProviderConfig = { - id: providerType, - name: nameMap[providerType as OAuthProviderType] || providerType, + id: accountId, + name: accountLabel || nameMap[providerType as OAuthProviderType] || providerType, type: providerType, enabled: existing?.enabled ?? true, baseUrl, // Save the dynamically resolved URL (Global vs CN) @@ -274,11 +290,11 @@ class DeviceOAuthManager extends EventEmitter { await saveProvider(providerConfig); // 4. Emit success internally so the main process can restart the Gateway - this.emit('oauth:success', providerType); + this.emit('oauth:success', { provider: providerType, accountId }); // 5. Emit success to frontend if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send('oauth:success', { provider: providerType, success: true }); + this.mainWindow.webContents.send('oauth:success', { provider: providerType, accountId, success: true }); } } @@ -331,12 +347,14 @@ class DeviceOAuthManager extends EventEmitter { userCode: string; expiresIn: number; }) { + this.emit('oauth:code', data); if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.webContents.send('oauth:code', data); } } private emitError(message: string) { + this.emit('oauth:error', { message }); if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.webContents.send('oauth:error', { message }); } diff --git a/electron/utils/gemini-cli-oauth.ts b/electron/utils/gemini-cli-oauth.ts new file mode 100644 index 000000000..991c0d359 --- /dev/null +++ b/electron/utils/gemini-cli-oauth.ts @@ -0,0 +1,738 @@ +import { execFile, execFileSync } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { delimiter, dirname, join } from 'node:path'; +import { getClawXConfigDir } from './paths'; + +const CLIENT_ID_KEYS = ['OPENCLAW_GEMINI_OAUTH_CLIENT_ID', 'GEMINI_CLI_OAUTH_CLIENT_ID']; +const CLIENT_SECRET_KEYS = [ + 'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET', + 'GEMINI_CLI_OAUTH_CLIENT_SECRET', +]; +const REDIRECT_URI = 'http://127.0.0.1:8085/oauth2callback'; +const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +const TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json'; +const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; +const SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +]; +const TIER_FREE = 'free-tier'; +const TIER_LEGACY = 'legacy-tier'; +const TIER_STANDARD = 'standard-tier'; +const LOCAL_GEMINI_DIR = join(getClawXConfigDir(), 'gemini-cli'); + +export type GeminiCliOAuthCredentials = { + access: string; + refresh: string; + expires: number; + email?: string; + projectId?: string; +}; + +export type GeminiCliOAuthContext = { + isRemote: boolean; + openUrl: (url: string) => Promise; + log: (msg: string) => void; + note: (message: string, title?: string) => Promise; + prompt: (message: string) => Promise; + progress: { update: (msg: string) => void; stop: (msg?: string) => void }; +}; + +export class DetailedError extends Error { + detail: string; + + constructor(message: string, detail: string) { + super(message); + this.name = 'DetailedError'; + this.detail = detail; + } +} + +let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; + +function resolveEnv(keys: string[]): string | undefined { + for (const key of keys) { + const value = process.env[key]?.trim(); + if (value) { + return value; + } + } + return undefined; +} + +function findInPath(name: string): string | null { + const exts = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : ['']; + for (const dir of (process.env.PATH ?? '').split(delimiter)) { + if (!dir) continue; + for (const ext of exts) { + const p = join(dir, name + ext); + if (existsSync(p)) { + return p; + } + } + } + return null; +} + +function findFile(dir: string, name: string, depth: number): string | null { + if (depth <= 0) { + return null; + } + + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const next = join(dir, entry.name); + if (entry.isFile() && entry.name === name) { + return next; + } + if (entry.isDirectory() && !entry.name.startsWith('.')) { + const found = findFile(next, name, depth - 1); + if (found) { + return found; + } + } + } + } catch { + return null; + } + + return null; +} + +export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { + if (cachedGeminiCliCredentials) { + return cachedGeminiCliCredentials; + } + + try { + const geminiPath = findInPath('gemini'); + if (!geminiPath) { + return null; + } + + const resolvedPath = realpathSync(geminiPath); + const geminiCliDir = dirname(dirname(resolvedPath)); + const searchPaths = [ + join( + geminiCliDir, + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js', + ), + join( + geminiCliDir, + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'code_assist', + 'oauth2.js', + ), + ]; + + let content: string | null = null; + for (const p of searchPaths) { + if (existsSync(p)) { + content = readFileSync(p, 'utf8'); + break; + } + } + + if (!content) { + const found = findFile(geminiCliDir, 'oauth2.js', 10); + if (found) { + content = readFileSync(found, 'utf8'); + } + } + + if (!content) { + return null; + } + + const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); + const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); + if (idMatch && secretMatch) { + cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] }; + return cachedGeminiCliCredentials; + } + } catch { + return null; + } + + return null; +} + +function extractFromLocalInstall(): { clientId: string; clientSecret: string } | null { + const coreDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core'); + if (!existsSync(coreDir)) { + return null; + } + + const searchPaths = [ + join(coreDir, 'dist', 'src', 'code_assist', 'oauth2.js'), + join(coreDir, 'dist', 'code_assist', 'oauth2.js'), + ]; + + let content: string | null = null; + for (const p of searchPaths) { + if (existsSync(p)) { + content = readFileSync(p, 'utf8'); + break; + } + } + + if (!content) { + const found = findFile(coreDir, 'oauth2.js', 10); + if (found) { + content = readFileSync(found, 'utf8'); + } + } + + if (!content) { + return null; + } + + const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); + const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); + if (idMatch && secretMatch) { + return { clientId: idMatch[1], clientSecret: secretMatch[1] }; + } + + return null; +} + +async function installViaNpm(onProgress?: (msg: string) => void): Promise { + const npmBin = findInPath('npm'); + if (!npmBin) { + return false; + } + + onProgress?.('Installing Gemini OAuth helper...'); + + return await new Promise((resolve) => { + const useShell = process.platform === 'win32'; + const child = execFile( + npmBin, + ['install', '--prefix', LOCAL_GEMINI_DIR, '@google/gemini-cli'], + { timeout: 120_000, shell: useShell, env: { ...process.env, NODE_ENV: '' } }, + (err) => { + if (err) { + onProgress?.(`Gemini helper install failed, falling back to direct download...`); + resolve(false); + } else { + cachedGeminiCliCredentials = null; + onProgress?.('Gemini OAuth helper installed'); + resolve(true); + } + }, + ); + child.stderr?.on('data', () => { + // Suppress npm noise. + }); + }); +} + +async function installViaDirectDownload(onProgress?: (msg: string) => void): Promise { + try { + onProgress?.('Downloading Gemini OAuth helper...'); + const metaRes = await fetch('https://registry.npmjs.org/@google/gemini-cli-core/latest'); + if (!metaRes.ok) { + onProgress?.(`Failed to fetch Gemini package metadata: ${metaRes.status}`); + return false; + } + + const meta = (await metaRes.json()) as { dist?: { tarball?: string } }; + const tarballUrl = meta.dist?.tarball; + if (!tarballUrl) { + onProgress?.('Gemini package tarball URL missing'); + return false; + } + + const tarRes = await fetch(tarballUrl); + if (!tarRes.ok) { + onProgress?.(`Failed to download Gemini package: ${tarRes.status}`); + return false; + } + + const buffer = Buffer.from(await tarRes.arrayBuffer()); + const targetDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core'); + mkdirSync(targetDir, { recursive: true }); + + const tmpFile = join(LOCAL_GEMINI_DIR, '_tmp_gemini-cli-core.tgz'); + writeFileSync(tmpFile, buffer); + try { + execFileSync('tar', ['xzf', tmpFile, '-C', targetDir, '--strip-components=1'], { + timeout: 30_000, + }); + } finally { + try { + unlinkSync(tmpFile); + } catch { + // ignore + } + } + + cachedGeminiCliCredentials = null; + onProgress?.('Gemini OAuth helper ready'); + return true; + } catch (err) { + onProgress?.(`Direct Gemini helper download failed: ${err instanceof Error ? err.message : String(err)}`); + return false; + } +} + +async function ensureOAuthClientConfig( + onProgress?: (msg: string) => void, +): Promise<{ clientId: string; clientSecret?: string }> { + const envClientId = resolveEnv(CLIENT_ID_KEYS); + const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); + if (envClientId) { + return { clientId: envClientId, clientSecret: envClientSecret }; + } + + const extracted = extractGeminiCliCredentials(); + if (extracted) { + return extracted; + } + + const localExtracted = extractFromLocalInstall(); + if (localExtracted) { + return localExtracted; + } + + mkdirSync(LOCAL_GEMINI_DIR, { recursive: true }); + const installed = await installViaNpm(onProgress) || await installViaDirectDownload(onProgress); + if (installed) { + const installedExtracted = extractFromLocalInstall(); + if (installedExtracted) { + return installedExtracted; + } + } + + throw new Error( + 'Unable to prepare Gemini OAuth credentials automatically. Set GEMINI_CLI_OAUTH_CLIENT_ID or try again later.', + ); +} + +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString('hex'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +function buildAuthUrl(clientId: string, challenge: string, verifier: string): string { + const params = new URLSearchParams({ + client_id: clientId, + response_type: 'code', + redirect_uri: REDIRECT_URI, + scope: SCOPES.join(' '), + code_challenge: challenge, + code_challenge_method: 'S256', + state: verifier, + access_type: 'offline', + prompt: 'consent', + }); + return `${AUTH_URL}?${params.toString()}`; +} + +async function waitForLocalCallback(params: { + expectedState: string; + timeoutMs: number; + onProgress?: (message: string) => void; +}): Promise<{ code: string; state: string }> { + const port = 8085; + const hostname = '127.0.0.1'; + const expectedPath = '/oauth2callback'; + + return new Promise((resolve, reject) => { + let timeout: NodeJS.Timeout | null = null; + const server = createServer((req, res) => { + try { + const requestUrl = new URL(req.url ?? '/', `http://${hostname}:${port}`); + if (requestUrl.pathname !== expectedPath) { + res.statusCode = 404; + res.setHeader('Content-Type', 'text/plain'); + res.end('Not found'); + return; + } + + const error = requestUrl.searchParams.get('error'); + const code = requestUrl.searchParams.get('code')?.trim(); + const state = requestUrl.searchParams.get('state')?.trim(); + + if (error) { + res.statusCode = 400; + res.setHeader('Content-Type', 'text/plain'); + res.end(`Authentication failed: ${error}`); + finish(new Error(`OAuth error: ${error}`)); + return; + } + + if (!code || !state) { + res.statusCode = 400; + res.setHeader('Content-Type', 'text/plain'); + res.end('Missing code or state'); + finish(new Error('Missing OAuth code or state')); + return; + } + + if (state !== params.expectedState) { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end( + "

Session expired

This authorization link is from a previous attempt. Please go back to ClawX and try again.

", + ); + return; + } + + res.statusCode = 200; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.end( + "

Gemini CLI OAuth complete

You can close this window and return to ClawX.

", + ); + + finish(undefined, { code, state }); + } catch (err) { + finish(err instanceof Error ? err : new Error('OAuth callback failed')); + } + }); + + const finish = (err?: Error, result?: { code: string; state: string }) => { + if (timeout) { + clearTimeout(timeout); + } + try { + server.close(); + } catch { + // ignore + } + if (err) { + reject(err); + } else if (result) { + resolve(result); + } + }; + + server.once('error', (err) => { + finish(err instanceof Error ? err : new Error('OAuth callback server error')); + }); + + server.listen(port, hostname, () => { + params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}...`); + }); + + timeout = setTimeout(() => { + finish(new DetailedError( + 'OAuth login timed out. The browser did not redirect back. Check if localhost:8085 is blocked.', + `Waited ${params.timeoutMs / 1000}s for callback on ${hostname}:${port}`, + )); + }, params.timeoutMs); + }); +} + +async function getUserEmail(accessToken: string): Promise { + try { + const response = await fetch(USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (response.ok) { + const data = (await response.json()) as { email?: string }; + return data.email; + } + } catch { + // ignore + } + + return undefined; +} + +function getDefaultTier( + allowedTiers?: Array<{ id?: string; isDefault?: boolean }>, +): { id?: string } | undefined { + if (!allowedTiers?.length) { + return { id: TIER_LEGACY }; + } + return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY }; +} + +function isVpcScAffected(payload: unknown): boolean { + if (!payload || typeof payload !== 'object') { + return false; + } + const error = (payload as { error?: unknown }).error; + if (!error || typeof error !== 'object') { + return false; + } + const details = (error as { details?: unknown[] }).details; + if (!Array.isArray(details)) { + return false; + } + return details.some( + (item) => + typeof item === 'object' + && item + && (item as { reason?: string }).reason === 'SECURITY_POLICY_VIOLATED', + ); +} + +async function pollOperation( + operationName: string, + headers: Record, +): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> { + for (let attempt = 0; attempt < 24; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers }); + if (!response.ok) { + continue; + } + const data = (await response.json()) as { + done?: boolean; + response?: { cloudaicompanionProject?: { id?: string } }; + }; + if (data.done) { + return data; + } + } + + throw new Error('Operation polling timeout'); +} + +async function discoverProject(accessToken: string): Promise { + const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; + const headers = { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'google-api-nodejs-client/9.15.1', + 'X-Goog-Api-Client': 'gl-node/clawx', + }; + + const loadBody = { + cloudaicompanionProject: envProject, + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + duetProject: envProject, + }, + }; + + let data: { + currentTier?: { id?: string }; + cloudaicompanionProject?: string | { id?: string }; + allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; + } = {}; + + const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, { + method: 'POST', + headers, + body: JSON.stringify(loadBody), + }); + + if (!response.ok) { + const errorPayload = await response.json().catch(() => null); + if (isVpcScAffected(errorPayload)) { + data = { currentTier: { id: TIER_STANDARD } }; + } else { + throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); + } + } else { + data = (await response.json()) as typeof data; + } + + if (data.currentTier) { + const project = data.cloudaicompanionProject; + if (typeof project === 'string' && project) { + return project; + } + if (typeof project === 'object' && project?.id) { + return project.id; + } + if (envProject) { + return envProject; + } + } + + const hasExistingTierButNoProject = !!data.currentTier; + const tier = hasExistingTierButNoProject ? { id: TIER_FREE } : getDefaultTier(data.allowedTiers); + const tierId = tier?.id || TIER_FREE; + if (tierId !== TIER_FREE && !envProject) { + throw new DetailedError( + 'Your Google account requires a Cloud project. Please create one and set GOOGLE_CLOUD_PROJECT.', + `tierId=${tierId}, currentTier=${JSON.stringify(data.currentTier ?? null)}, allowedTiers=${JSON.stringify(data.allowedTiers)}`, + ); + } + + const onboardBody: Record = { + tierId, + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + }, + }; + if (tierId !== TIER_FREE && envProject) { + onboardBody.cloudaicompanionProject = envProject; + (onboardBody.metadata as Record).duetProject = envProject; + } + + const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { + method: 'POST', + headers, + body: JSON.stringify(onboardBody), + }); + + if (!onboardResponse.ok) { + const respText = await onboardResponse.text().catch(() => ''); + throw new DetailedError( + 'Google project provisioning failed. Please try again later.', + `onboardUser ${onboardResponse.status} ${onboardResponse.statusText}: ${respText}`, + ); + } + + let lro = (await onboardResponse.json()) as { + done?: boolean; + name?: string; + response?: { cloudaicompanionProject?: { id?: string } }; + }; + + if (!lro.done && lro.name) { + lro = await pollOperation(lro.name, headers); + } + + const projectId = lro.response?.cloudaicompanionProject?.id; + if (projectId) { + return projectId; + } + if (envProject) { + return envProject; + } + + throw new DetailedError( + 'Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.', + `tierId=${tierId}, onboardResponse=${JSON.stringify(lro)}, currentTier=${JSON.stringify(data.currentTier ?? null)}`, + ); +} + +async function exchangeCodeForTokens( + code: string, + verifier: string, + clientConfig: { clientId: string; clientSecret?: string }, +): Promise { + const { clientId, clientSecret } = clientConfig; + const body = new URLSearchParams({ + client_id: clientId, + code, + grant_type: 'authorization_code', + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }); + if (clientSecret) { + body.set('client_secret', clientSecret); + } + + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token exchange failed: ${errorText}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + if (!data.refresh_token) { + throw new Error('No refresh token received. Please try again.'); + } + + const email = await getUserEmail(data.access_token); + const projectId = await discoverProject(data.access_token); + const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; + + return { + refresh: data.refresh_token, + access: data.access_token, + expires: expiresAt, + projectId, + email, + }; +} + +export async function loginGeminiCliOAuth( + ctx: GeminiCliOAuthContext, +): Promise { + if (ctx.isRemote) { + throw new Error('Remote/manual Gemini OAuth is not implemented in ClawX yet.'); + } + + await ctx.note( + [ + 'Browser will open for Google authentication.', + 'Sign in with your Google account for Gemini CLI access.', + 'The callback will be captured automatically on 127.0.0.1:8085.', + ].join('\n'), + 'Gemini CLI OAuth', + ); + + ctx.progress.update('Preparing Google OAuth...'); + const clientConfig = await ensureOAuthClientConfig((msg) => ctx.progress.update(msg)); + const { verifier, challenge } = generatePkce(); + const authUrl = buildAuthUrl(clientConfig.clientId, challenge, verifier); + ctx.progress.update('Complete sign-in in browser...'); + + try { + await ctx.openUrl(authUrl); + } catch { + ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`); + } + + try { + const { code } = await waitForLocalCallback({ + expectedState: verifier, + timeoutMs: 5 * 60 * 1000, + onProgress: (msg) => ctx.progress.update(msg), + }); + ctx.progress.update('Exchanging authorization code for tokens...'); + return await exchangeCodeForTokens(code, verifier, clientConfig); + } catch (err) { + if ( + err instanceof Error + && (err.message.includes('EADDRINUSE') + || err.message.includes('port') + || err.message.includes('listen')) + ) { + throw new Error( + 'Port 8085 is in use by another process. Close the other application using port 8085 and try again.', + { cause: err }, + ); + } + throw err; + } +} + +// Best-effort check to help with diagnostics if the user claims gemini is installed but PATH is stale. +export function detectGeminiCliVersion(): string | null { + try { + const geminiPath = findInPath('gemini'); + if (!geminiPath) { + return null; + } + return execFileSync(geminiPath, ['--version'], { encoding: 'utf8' }).trim(); + } catch { + return null; + } +} diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index fd887783a..cf4a85721 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -26,6 +26,10 @@ import { const AUTH_STORE_VERSION = 1; const AUTH_PROFILE_FILENAME = 'auth-profiles.json'; +function getOAuthPluginId(provider: string): string { + return `${provider}-auth`; +} + // ── Helpers ────────────────────────────────────────────────────── /** Non-throwing async existence check (replaces existsSync). */ @@ -76,6 +80,8 @@ interface OAuthProfileEntry { access: string; refresh: string; expires: number; + email?: string; + projectId?: string; } interface AuthProfilesStore { @@ -155,7 +161,7 @@ async function writeOpenClawJson(config: Record): Promise */ export async function saveOAuthTokenToOpenClaw( provider: string, - token: { access: string; refresh: string; expires: number }, + token: { access: string; refresh: string; expires: number; email?: string; projectId?: string }, agentId?: string ): Promise { const agentIds = agentId ? [agentId] : await discoverAgentIds(); @@ -171,6 +177,8 @@ export async function saveOAuthTokenToOpenClaw( access: token.access, refresh: token.refresh, expires: token.expires, + email: token.email, + projectId: token.projectId, }; if (!store.order) store.order = {}; @@ -378,7 +386,10 @@ export async function setOpenClawDefaultModel( const config = await readOpenClawJson(); ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); - const model = modelOverride || getProviderDefaultModel(provider); + const rawModel = modelOverride || getProviderDefaultModel(provider); + const model = rawModel + ? (rawModel.startsWith(`${provider}/`) ? rawModel : `${provider}/${rawModel}`) + : undefined; if (!model) { console.warn(`No default model mapping for provider "${provider}"`); return; @@ -542,8 +553,14 @@ export async function syncProviderConfigToOpenClaw( // Ensure extension is enabled for oauth providers to prevent gateway wiping config if (isOpenClawOAuthPluginProviderKey(provider)) { const plugins = (config.plugins || {}) as Record; + const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : []; const pEntries = (plugins.entries || {}) as Record; - pEntries[`${provider}-auth`] = { enabled: true }; + const pluginId = getOAuthPluginId(provider); + if (!allow.includes(pluginId)) { + allow.push(pluginId); + } + pEntries[pluginId] = { enabled: true }; + plugins.allow = allow; plugins.entries = pEntries; config.plugins = plugins; } @@ -563,7 +580,10 @@ export async function setOpenClawDefaultModelWithOverride( const config = await readOpenClawJson(); ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); - const model = modelOverride || getProviderDefaultModel(provider); + const rawModel = modelOverride || getProviderDefaultModel(provider); + const model = rawModel + ? (rawModel.startsWith(`${provider}/`) ? rawModel : `${provider}/${rawModel}`) + : undefined; if (!model) { console.warn(`No default model mapping for provider "${provider}"`); return; @@ -622,8 +642,14 @@ export async function setOpenClawDefaultModelWithOverride( // Ensure the extension plugin is marked as enabled in openclaw.json if (isOpenClawOAuthPluginProviderKey(provider)) { const plugins = (config.plugins || {}) as Record; + const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : []; const pEntries = (plugins.entries || {}) as Record; - pEntries[`${provider}-auth`] = { enabled: true }; + const pluginId = getOAuthPluginId(provider); + if (!allow.includes(pluginId)) { + allow.push(pluginId); + } + pEntries[pluginId] = { enabled: true }; + plugins.allow = allow; plugins.entries = pEntries; config.plugins = plugins; } @@ -689,6 +715,22 @@ export async function syncGatewayTokenToConfig(token: string): Promise { auth.mode = 'token'; auth.token = token; gateway.auth = auth; + + // Packaged ClawX loads the renderer from file://, so the gateway must allow + // that origin for the chat WebSocket handshake. + const controlUi = ( + gateway.controlUi && typeof gateway.controlUi === 'object' + ? { ...(gateway.controlUi as Record) } + : {} + ) as Record; + const allowedOrigins = Array.isArray(controlUi.allowedOrigins) + ? (controlUi.allowedOrigins as unknown[]).filter((value): value is string => typeof value === 'string') + : []; + if (!allowedOrigins.includes('file://')) { + controlUi.allowedOrigins = [...allowedOrigins, 'file://']; + } + gateway.controlUi = controlUi; + if (!gateway.mode) gateway.mode = 'local'; config.gateway = gateway; diff --git a/electron/utils/provider-registry.ts b/electron/utils/provider-registry.ts index 4ed73f1a9..108a5e84f 100644 --- a/electron/utils/provider-registry.ts +++ b/electron/utils/provider-registry.ts @@ -1,147 +1,25 @@ /** - * Provider Registry — single source of truth for backend provider metadata. - * Centralizes env var mappings, default models, and OpenClaw provider configs. - * - * NOTE: When adding a new provider type, also update src/lib/providers.ts + * Backend compatibility layer around the shared provider registry. */ -export const BUILTIN_PROVIDER_TYPES = [ - 'anthropic', - 'openai', - 'google', - 'openrouter', - 'ark', - 'moonshot', - 'siliconflow', - 'minimax-portal', - 'minimax-portal-cn', - 'qwen-portal', - 'ollama', -] as const; -export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number]; -export type ProviderType = BuiltinProviderType | 'custom'; +export { + BUILTIN_PROVIDER_TYPES, + type BuiltinProviderType, + type ProviderType, +} from '../shared/providers/types'; +import { + type ProviderBackendConfig, + type ProviderModelEntry, +} from '../shared/providers/types'; +import { + getKeyableProviderTypes as getSharedKeyableProviderTypes, + getProviderBackendConfig, + getProviderDefaultModel as getSharedProviderDefaultModel, + getProviderEnvVar as getSharedProviderEnvVar, +} from '../shared/providers/registry'; -interface ProviderModelEntry extends Record { - id: string; - name: string; -} - - -interface ProviderBackendMeta { - envVar?: string; - defaultModel?: string; - /** OpenClaw models.providers config (omit for built-in providers like anthropic) */ - providerConfig?: { - baseUrl: string; - api: string; - apiKeyEnv: string; - models?: ProviderModelEntry[]; - headers?: Record; - }; -} - -const REGISTRY: Record = { - anthropic: { - envVar: 'ANTHROPIC_API_KEY', - defaultModel: 'anthropic/claude-opus-4-6', - // anthropic is built-in to OpenClaw's model registry, no provider config needed - }, - openai: { - envVar: 'OPENAI_API_KEY', - defaultModel: 'openai/gpt-5.2', - providerConfig: { - baseUrl: 'https://api.openai.com/v1', - api: 'openai-responses', - apiKeyEnv: 'OPENAI_API_KEY', - }, - }, - google: { - envVar: 'GEMINI_API_KEY', - defaultModel: 'google/gemini-3.1-pro-preview', - // google is built-in to OpenClaw's pi-ai catalog, no providerConfig needed. - // Adding models.providers.google overrides the built-in and can break Gemini. - }, - openrouter: { - envVar: 'OPENROUTER_API_KEY', - defaultModel: 'openrouter/anthropic/claude-opus-4.6', - providerConfig: { - baseUrl: 'https://openrouter.ai/api/v1', - api: 'openai-completions', - apiKeyEnv: 'OPENROUTER_API_KEY', - headers: { - 'HTTP-Referer': 'https://claw-x.com', - 'X-Title': 'ClawX', - }, - }, - }, - ark: { - envVar: 'ARK_API_KEY', - providerConfig: { - baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', - api: 'openai-completions', - apiKeyEnv: 'ARK_API_KEY', - }, - }, - moonshot: { - envVar: 'MOONSHOT_API_KEY', - defaultModel: 'moonshot/kimi-k2.5', - providerConfig: { - baseUrl: 'https://api.moonshot.cn/v1', - api: 'openai-completions', - apiKeyEnv: 'MOONSHOT_API_KEY', - models: [ - { - id: 'kimi-k2.5', - name: 'Kimi K2.5', - reasoning: false, - input: ['text'], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 256000, - maxTokens: 8192, - }, - ], - }, - }, - siliconflow: { - envVar: 'SILICONFLOW_API_KEY', - defaultModel: 'siliconflow/deepseek-ai/DeepSeek-V3', - providerConfig: { - baseUrl: 'https://api.siliconflow.cn/v1', - api: 'openai-completions', - apiKeyEnv: 'SILICONFLOW_API_KEY', - }, - }, - 'minimax-portal': { - envVar: 'MINIMAX_API_KEY', - defaultModel: 'minimax-portal/MiniMax-M2.5', - providerConfig: { - baseUrl: 'https://api.minimax.io/anthropic', - api: 'anthropic-messages', - apiKeyEnv: 'MINIMAX_API_KEY', - }, - }, - 'minimax-portal-cn': { - envVar: 'MINIMAX_CN_API_KEY', - defaultModel: 'minimax-portal/MiniMax-M2.5', - providerConfig: { - baseUrl: 'https://api.minimaxi.com/anthropic', - api: 'anthropic-messages', - apiKeyEnv: 'MINIMAX_CN_API_KEY', - }, - }, - 'qwen-portal': { - envVar: 'QWEN_API_KEY', - defaultModel: 'qwen-portal/coder-model', - providerConfig: { - baseUrl: 'https://portal.qwen.ai/v1', - api: 'openai-completions', - apiKeyEnv: 'QWEN_API_KEY', - }, - }, - custom: { - envVar: 'CUSTOM_API_KEY', - }, - // Additional providers with env var mappings but no default model +// Additional env-backed providers that are not yet exposed in the UI. +const EXTRA_ENV_ONLY_PROVIDERS: Record = { groq: { envVar: 'GROQ_API_KEY' }, deepgram: { envVar: 'DEEPGRAM_API_KEY' }, cerebras: { envVar: 'CEREBRAS_API_KEY' }, @@ -151,26 +29,25 @@ const REGISTRY: Record = { /** Get the environment variable name for a provider type */ export function getProviderEnvVar(type: string): string | undefined { - return REGISTRY[type]?.envVar; + return getSharedProviderEnvVar(type) ?? EXTRA_ENV_ONLY_PROVIDERS[type]?.envVar; } /** Get all environment variable names for a provider type (primary first). */ export function getProviderEnvVars(type: string): string[] { - const meta = REGISTRY[type]; - if (!meta?.envVar) return []; - return [meta.envVar]; + const envVar = getProviderEnvVar(type); + return envVar ? [envVar] : []; } /** Get the default model string for a provider type */ export function getProviderDefaultModel(type: string): string | undefined { - return REGISTRY[type]?.defaultModel; + return getSharedProviderDefaultModel(type); } /** Get the OpenClaw provider config (baseUrl, api, apiKeyEnv, models, headers) */ export function getProviderConfig( type: string ): { baseUrl: string; api: string; apiKeyEnv: string; models?: ProviderModelEntry[]; headers?: Record } | undefined { - return REGISTRY[type]?.providerConfig; + return getProviderBackendConfig(type) as ProviderBackendConfig | undefined; } /** @@ -178,7 +55,5 @@ export function getProviderConfig( * Used by GatewayManager to inject API keys as env vars. */ export function getKeyableProviderTypes(): string[] { - return Object.entries(REGISTRY) - .filter(([, meta]) => meta.envVar) - .map(([type]) => type); + return [...getSharedKeyableProviderTypes(), ...Object.keys(EXTRA_ENV_ONLY_PROVIDERS)]; } diff --git a/electron/utils/secure-storage.ts b/electron/utils/secure-storage.ts index 6452063d8..023d3276c 100644 --- a/electron/utils/secure-storage.ts +++ b/electron/utils/secure-storage.ts @@ -1,32 +1,30 @@ /** * Provider Storage * Manages provider configurations and API keys. - * Keys are stored in plain text alongside provider configs in a single electron-store. + * This file remains the legacy compatibility layer while the app migrates to + * account-based provider storage and a dedicated secret-store abstraction. */ import { BUILTIN_PROVIDER_TYPES, type ProviderType } from './provider-registry'; import { getActiveOpenClawProviders } from './openclaw-auth'; +import { + deleteProviderAccount, + getProviderAccount, + listProviderAccounts, + providerAccountToConfig, + providerConfigToAccount, + saveProviderAccount, + setDefaultProviderAccount, +} from '../services/providers/provider-store'; +import { ensureProviderStoreMigrated } from '../services/providers/provider-migration'; +import { getClawXProviderStore } from '../services/providers/store-instance'; +import { + deleteProviderSecret, + getProviderSecret, + setProviderSecret, +} from '../services/secrets/secret-store'; import { getOpenClawProviderKeyForType } from './provider-keys'; -// Lazy-load electron-store (ESM module) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let providerStore: any = null; - -async function getProviderStore() { - if (!providerStore) { - const Store = (await import('electron-store')).default; - providerStore = new Store({ - name: 'clawx-providers', - defaults: { - providers: {} as Record, - apiKeys: {} as Record, - defaultProvider: null as string | null, - }, - }); - } - return providerStore; -} - /** * Provider configuration */ @@ -50,10 +48,16 @@ export interface ProviderConfig { */ export async function storeApiKey(providerId: string, apiKey: string): Promise { try { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); const keys = (s.get('apiKeys') || {}) as Record; keys[providerId] = apiKey; s.set('apiKeys', keys); + await setProviderSecret({ + type: 'api_key', + accountId: providerId, + apiKey, + }); return true; } catch (error) { console.error('Failed to store API key:', error); @@ -66,7 +70,16 @@ export async function storeApiKey(providerId: string, apiKey: string): Promise { try { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const secret = await getProviderSecret(providerId); + if (secret?.type === 'api_key') { + return secret.apiKey; + } + if (secret?.type === 'local') { + return secret.apiKey ?? null; + } + + const s = await getClawXProviderStore(); const keys = (s.get('apiKeys') || {}) as Record; return keys[providerId] || null; } catch (error) { @@ -80,10 +93,12 @@ export async function getApiKey(providerId: string): Promise { */ export async function deleteApiKey(providerId: string): Promise { try { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); const keys = (s.get('apiKeys') || {}) as Record; delete keys[providerId]; s.set('apiKeys', keys); + await deleteProviderSecret(providerId); return true; } catch (error) { console.error('Failed to delete API key:', error); @@ -95,7 +110,13 @@ export async function deleteApiKey(providerId: string): Promise { * Check if an API key exists for a provider */ export async function hasApiKey(providerId: string): Promise { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const secret = await getProviderSecret(providerId); + if (secret?.type === 'api_key') { + return true; + } + + const s = await getClawXProviderStore(); const keys = (s.get('apiKeys') || {}) as Record; return providerId in keys; } @@ -104,7 +125,8 @@ export async function hasApiKey(providerId: string): Promise { * List all provider IDs that have stored keys */ export async function listStoredKeyIds(): Promise { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); const keys = (s.get('apiKeys') || {}) as Record; return Object.keys(keys); } @@ -115,28 +137,47 @@ export async function listStoredKeyIds(): Promise { * Save a provider configuration */ export async function saveProvider(config: ProviderConfig): Promise { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); const providers = s.get('providers') as Record; providers[config.id] = config; s.set('providers', providers); + + const defaultProviderId = (s.get('defaultProvider') ?? null) as string | null; + await saveProviderAccount( + providerConfigToAccount(config, { isDefault: defaultProviderId === config.id }), + ); } /** * Get a provider configuration */ export async function getProvider(providerId: string): Promise { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); const providers = s.get('providers') as Record; - return providers[providerId] || null; + if (providers[providerId]) { + return providers[providerId]; + } + + const account = await getProviderAccount(providerId); + return account ? providerAccountToConfig(account) : null; } /** * Get all provider configurations */ export async function getAllProviders(): Promise { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); const providers = s.get('providers') as Record; - return Object.values(providers); + const legacyProviders = Object.values(providers); + if (legacyProviders.length > 0) { + return legacyProviders; + } + + const accounts = await listProviderAccounts(); + return accounts.map(providerAccountToConfig); } /** @@ -144,18 +185,21 @@ export async function getAllProviders(): Promise { */ export async function deleteProvider(providerId: string): Promise { try { + await ensureProviderStoreMigrated(); // Delete the API key await deleteApiKey(providerId); // Delete the provider config - const s = await getProviderStore(); + const s = await getClawXProviderStore(); const providers = s.get('providers') as Record; delete providers[providerId]; s.set('providers', providers); + await deleteProviderAccount(providerId); // Clear default if this was the default if (s.get('defaultProvider') === providerId) { s.delete('defaultProvider'); + s.delete('defaultProviderAccountId'); } return true; @@ -169,16 +213,20 @@ export async function deleteProvider(providerId: string): Promise { * Set the default provider */ export async function setDefaultProvider(providerId: string): Promise { - const s = await getProviderStore(); + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); s.set('defaultProvider', providerId); + await setDefaultProviderAccount(providerId); } /** * Get the default provider */ export async function getDefaultProvider(): Promise { - const s = await getProviderStore(); - return s.get('defaultProvider') as string | undefined; + await ensureProviderStoreMigrated(); + const s = await getClawXProviderStore(); + return (s.get('defaultProvider') as string | undefined) + ?? (s.get('defaultProviderAccountId') as string | undefined); } /** diff --git a/package.json b/package.json index 71200d440..7e972cd1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawx", - "version": "0.1.23", + "version": "0.1.24-alpha.1", "pnpm": { "onlyBuiltDependencies": [ "@whiskeysockets/baileys", @@ -107,4 +107,4 @@ "zx": "^8.8.5" }, "packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8" -} +} \ No newline at end of file diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e31c4fa7f..87940f72f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -24,7 +24,7 @@ import { useChatStore } from '@/stores/chat'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { invokeIpc } from '@/lib/api-client'; +import { hostApiFetch } from '@/lib/host-api'; import { useTranslation } from 'react-i18next'; type SessionBucketKey = @@ -115,11 +115,11 @@ export function Sidebar() { const openDevConsole = async () => { try { - const result = await invokeIpc('gateway:getControlUiUrl') as { + const result = await hostApiFetch<{ success: boolean; url?: string; error?: string; - }; + }>('/api/gateway/control-ui'); if (result.success && result.url) { window.electron.openExternal(result.url); } else { @@ -297,4 +297,4 @@ export function Sidebar() { /> ); -} +} \ No newline at end of file diff --git a/src/components/settings/ProvidersSettings.tsx b/src/components/settings/ProvidersSettings.tsx index d2bdb5fd3..342f4303f 100644 --- a/src/components/settings/ProvidersSettings.tsx +++ b/src/components/settings/ProvidersSettings.tsx @@ -2,7 +2,7 @@ * Providers Settings Component * Manage AI provider configurations and API keys */ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Plus, Trash2, @@ -24,7 +24,12 @@ import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; -import { useProviderStore, type ProviderConfig, type ProviderWithKeyInfo } from '@/stores/providers'; +import { + useProviderStore, + type ProviderAccount, + type ProviderConfig, + type ProviderVendorInfo, +} from '@/stores/providers'; import { PROVIDER_TYPE_INFO, type ProviderType, @@ -34,11 +39,18 @@ import { shouldShowProviderModelId, shouldInvertInDark, } from '@/lib/providers'; +import { + buildProviderAccountId, + buildProviderListItems, + type ProviderListItem, +} from '@/lib/provider-accounts'; import { cn } from '@/lib/utils'; -import { invokeIpc } from '@/lib/api-client'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; +import { invokeIpc } from '@/lib/api-client'; import { useSettingsStore } from '@/stores/settings'; +import { hostApiFetch } from '@/lib/host-api'; +import { subscribeHostEvent } from '@/lib/host-events'; function normalizeFallbackProviderIds(ids?: string[]): string[] { return Array.from(new Set((ids ?? []).filter(Boolean))); @@ -60,55 +72,82 @@ function fallbackModelsEqual(a?: string[], b?: string[]): boolean { return left.length === right.length && left.every((model, index) => model === right[index]); } +function getAuthModeLabel( + authMode: ProviderAccount['authMode'], + t: (key: string) => string +): string { + switch (authMode) { + case 'api_key': + return t('aiProviders.authModes.apiKey'); + case 'oauth_device': + return t('aiProviders.authModes.oauthDevice'); + case 'oauth_browser': + return t('aiProviders.authModes.oauthBrowser'); + case 'local': + return t('aiProviders.authModes.local'); + default: + return authMode; + } +} + export function ProvidersSettings() { const { t } = useTranslation('settings'); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); const { - providers, - defaultProviderId, + statuses, + accounts, + vendors, + defaultAccountId, loading, - fetchProviders, - addProvider, - deleteProvider, - updateProviderWithKey, - setDefaultProvider, - validateApiKey, + refreshProviderSnapshot, + createAccount, + removeAccount, + updateAccount, + setDefaultAccount, + validateAccountApiKey, } = useProviderStore(); const [showAddDialog, setShowAddDialog] = useState(false); const [editingProvider, setEditingProvider] = useState(null); + const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor])); + const existingVendorIds = new Set(accounts.map((account) => account.vendorId)); + const displayProviders = useMemo( + () => buildProviderListItems(accounts, statuses, vendors, defaultAccountId), + [accounts, statuses, vendors, defaultAccountId], + ); // Fetch providers on mount useEffect(() => { - fetchProviders(); - }, [fetchProviders]); + refreshProviderSnapshot(); + }, [refreshProviderSnapshot]); const handleAddProvider = async ( type: ProviderType, name: string, apiKey: string, - options?: { baseUrl?: string; model?: string } + options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode'] } ) => { - // Only custom supports multiple instances. - // Built-in providers remain singleton by type. - const id = type === 'custom' ? `custom-${crypto.randomUUID()}` : type; + const vendor = vendorMap.get(type); + const id = buildProviderAccountId(type, null, vendors); const effectiveApiKey = resolveProviderApiKeyForSave(type, apiKey); try { - await addProvider( - { - id, - type, - name, - baseUrl: options?.baseUrl, - model: options?.model, - enabled: true, - }, - effectiveApiKey - ); + await createAccount({ + id, + vendorId: type, + label: name, + authMode: options?.authMode || vendor?.defaultAuthMode || (type === 'ollama' ? 'local' : 'api_key'), + baseUrl: options?.baseUrl, + apiProtocol: type === 'custom' || type === 'ollama' ? 'openai-completions' : undefined, + model: options?.model, + enabled: true, + isDefault: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, effectiveApiKey); // Auto-set as default if no default is currently configured - if (!defaultProviderId) { - await setDefaultProvider(id); + if (!defaultAccountId) { + await setDefaultAccount(id); } setShowAddDialog(false); @@ -120,7 +159,7 @@ export function ProvidersSettings() { const handleDeleteProvider = async (providerId: string) => { try { - await deleteProvider(providerId); + await removeAccount(providerId); toast.success(t('aiProviders.toast.deleted')); } catch (error) { toast.error(`${t('aiProviders.toast.failedDelete')}: ${error}`); @@ -129,7 +168,7 @@ export function ProvidersSettings() { const handleSetDefault = async (providerId: string) => { try { - await setDefaultProvider(providerId); + await setDefaultAccount(providerId); toast.success(t('aiProviders.toast.defaultUpdated')); } catch (error) { toast.error(`${t('aiProviders.toast.failedDefault')}: ${error}`); @@ -149,7 +188,7 @@ export function ProvidersSettings() {
- ) : providers.length === 0 ? ( + ) : displayProviders.length === 0 ? ( @@ -165,26 +204,35 @@ export function ProvidersSettings() { ) : (
- {providers.map((provider) => ( + {displayProviders.map((item) => ( setEditingProvider(provider.id)} + key={item.account.id} + item={item} + allProviders={displayProviders} + isDefault={item.account.id === defaultAccountId} + isEditing={editingProvider === item.account.id} + onEdit={() => setEditingProvider(item.account.id)} onCancelEdit={() => setEditingProvider(null)} - onDelete={() => handleDeleteProvider(provider.id)} - onSetDefault={() => handleSetDefault(provider.id)} + onDelete={() => handleDeleteProvider(item.account.id)} + onSetDefault={() => handleSetDefault(item.account.id)} onSaveEdits={async (payload) => { - await updateProviderWithKey( - provider.id, - payload.updates || {}, + const updates: Partial = {}; + if (payload.updates) { + if (payload.updates.baseUrl !== undefined) updates.baseUrl = payload.updates.baseUrl; + if (payload.updates.model !== undefined) updates.model = payload.updates.model; + if (payload.updates.fallbackModels !== undefined) updates.fallbackModels = payload.updates.fallbackModels; + if (payload.updates.fallbackProviderIds !== undefined) { + updates.fallbackAccountIds = payload.updates.fallbackProviderIds; + } + } + await updateAccount( + item.account.id, + updates, payload.newApiKey ); setEditingProvider(null); }} - onValidateKey={(key, options) => validateApiKey(provider.id, key, options)} + onValidateKey={(key, options) => validateAccountApiKey(item.account.id, key, options)} devModeUnlocked={devModeUnlocked} /> ))} @@ -194,10 +242,11 @@ export function ProvidersSettings() { {/* Add Provider Dialog */} {showAddDialog && ( p.type))} + existingVendorIds={existingVendorIds} + vendors={vendors} onClose={() => setShowAddDialog(false)} onAdd={handleAddProvider} - onValidateKey={(type, key, options) => validateApiKey(type, key, options)} + onValidateKey={(type, key, options) => validateAccountApiKey(type, key, options)} devModeUnlocked={devModeUnlocked} /> )} @@ -206,8 +255,8 @@ export function ProvidersSettings() { } interface ProviderCardProps { - provider: ProviderWithKeyInfo; - allProviders: ProviderWithKeyInfo[]; + item: ProviderListItem; + allProviders: ProviderListItem[]; isDefault: boolean; isEditing: boolean; onEdit: () => void; @@ -225,7 +274,7 @@ interface ProviderCardProps { function ProviderCard({ - provider, + item, allProviders, isDefault, isEditing, @@ -238,20 +287,21 @@ function ProviderCard({ devModeUnlocked, }: ProviderCardProps) { const { t } = useTranslation('settings'); + const { account, vendor, status } = item; const [newKey, setNewKey] = useState(''); - const [baseUrl, setBaseUrl] = useState(provider.baseUrl || ''); - const [modelId, setModelId] = useState(provider.model || ''); + const [baseUrl, setBaseUrl] = useState(account.baseUrl || ''); + const [modelId, setModelId] = useState(account.model || ''); const [fallbackModelsText, setFallbackModelsText] = useState( - normalizeFallbackModels(provider.fallbackModels).join('\n') + normalizeFallbackModels(account.fallbackModels).join('\n') ); const [fallbackProviderIds, setFallbackProviderIds] = useState( - normalizeFallbackProviderIds(provider.fallbackProviderIds) + normalizeFallbackProviderIds(account.fallbackAccountIds) ); const [showKey, setShowKey] = useState(false); const [validating, setValidating] = useState(false); const [saving, setSaving] = useState(false); - const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === provider.type); + const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === account.vendorId); const showModelIdField = shouldShowProviderModelId(typeInfo, devModeUnlocked); const canEditModelConfig = Boolean(typeInfo?.showBaseUrl || showModelIdField); @@ -259,14 +309,14 @@ function ProviderCard({ if (isEditing) { setNewKey(''); setShowKey(false); - setBaseUrl(provider.baseUrl || ''); - setModelId(provider.model || ''); - setFallbackModelsText(normalizeFallbackModels(provider.fallbackModels).join('\n')); - setFallbackProviderIds(normalizeFallbackProviderIds(provider.fallbackProviderIds)); + setBaseUrl(account.baseUrl || ''); + setModelId(account.model || ''); + setFallbackModelsText(normalizeFallbackModels(account.fallbackModels).join('\n')); + setFallbackProviderIds(normalizeFallbackProviderIds(account.fallbackAccountIds)); } - }, [isEditing, provider.baseUrl, provider.fallbackModels, provider.fallbackProviderIds, provider.model]); + }, [isEditing, account.baseUrl, account.fallbackModels, account.fallbackAccountIds, account.model]); - const fallbackOptions = allProviders.filter((candidate) => candidate.id !== provider.id); + const fallbackOptions = allProviders.filter((candidate) => candidate.account.id !== account.id); const toggleFallbackProvider = (providerId: string) => { setFallbackProviderIds((current) => ( @@ -304,16 +354,16 @@ function ProviderCard({ } const updates: Partial = {}; - if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (provider.baseUrl || undefined)) { + if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (account.baseUrl || undefined)) { updates.baseUrl = baseUrl.trim() || undefined; } - if (showModelIdField && (modelId.trim() || undefined) !== (provider.model || undefined)) { + if (showModelIdField && (modelId.trim() || undefined) !== (account.model || undefined)) { updates.model = modelId.trim() || undefined; } - if (!fallbackModelsEqual(normalizedFallbackModels, provider.fallbackModels)) { + if (!fallbackModelsEqual(normalizedFallbackModels, account.fallbackModels)) { updates.fallbackModels = normalizedFallbackModels; } - if (!fallbackProviderIdsEqual(fallbackProviderIds, provider.fallbackProviderIds)) { + if (!fallbackProviderIdsEqual(fallbackProviderIds, account.fallbackAccountIds)) { updates.fallbackProviderIds = normalizeFallbackProviderIds(fallbackProviderIds); } if (Object.keys(updates).length > 0) { @@ -323,8 +373,8 @@ function ProviderCard({ // Keep Ollama key optional in UI, but persist a placeholder when // editing legacy configs that have no stored key. - if (provider.type === 'ollama' && !provider.hasKey && !payload.newApiKey) { - payload.newApiKey = resolveProviderApiKeyForSave(provider.type, '') as string; + if (account.vendorId === 'ollama' && !status?.hasKey && !payload.newApiKey) { + payload.newApiKey = resolveProviderApiKeyForSave(account.vendorId, '') as string; } if (!payload.newApiKey && !payload.updates) { @@ -350,16 +400,23 @@ function ProviderCard({ {/* Top row: icon + name */}
- {getProviderIconUrl(provider.type) ? ( - {typeInfo?.name + {getProviderIconUrl(account.vendorId) ? ( + {typeInfo?.name ) : ( - {typeInfo?.icon || '⚙️'} + {vendor?.icon || typeInfo?.icon || '⚙️'} )}
- {provider.name} + {account.label} + {vendor?.name || account.vendorId} + {getAuthModeLabel(account.authMode, t)} +
+
+

{account.vendorId}

+

+ {t('aiProviders.dialog.modelId')}: {account.model || t('aiProviders.card.none')} +

- {provider.type}
@@ -415,14 +472,16 @@ function ProviderCard({ ) : (
{fallbackOptions.map((candidate) => ( -
@@ -434,12 +493,12 @@ function ProviderCard({

- {provider.hasKey + {status?.hasKey ? t('aiProviders.dialog.apiKeyConfigured') : t('aiProviders.dialog.apiKeyMissing')}

- {provider.hasKey ? ( + {status?.hasKey ? ( {t('aiProviders.card.configured')} ) : null}
@@ -484,10 +543,10 @@ function ProviderCard({ || saving || ( !newKey.trim() - && (baseUrl.trim() || undefined) === (provider.baseUrl || undefined) - && (modelId.trim() || undefined) === (provider.model || undefined) - && fallbackModelsEqual(normalizeFallbackModels(fallbackModelsText.split('\n')), provider.fallbackModels) - && fallbackProviderIdsEqual(fallbackProviderIds, provider.fallbackProviderIds) + && (baseUrl.trim() || undefined) === (account.baseUrl || undefined) + && (modelId.trim() || undefined) === (account.model || undefined) + && fallbackModelsEqual(normalizeFallbackModels(fallbackModelsText.split('\n')), account.fallbackModels) + && fallbackProviderIdsEqual(fallbackProviderIds, account.fallbackAccountIds) ) || Boolean(showModelIdField && !modelId.trim()) } @@ -512,7 +571,7 @@ function ProviderCard({
- {typeInfo?.isOAuth ? ( + {account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' ? ( <> {t('aiProviders.card.configured')} @@ -521,13 +580,13 @@ function ProviderCard({ <> - {provider.hasKey - ? (provider.keyMasked && provider.keyMasked.length > 12 - ? `${provider.keyMasked.substring(0, 4)}...${provider.keyMasked.substring(provider.keyMasked.length - 4)}` - : provider.keyMasked) + {status?.hasKey + ? (status.keyMasked && status.keyMasked.length > 12 + ? `${status.keyMasked.substring(0, 4)}...${status.keyMasked.substring(status.keyMasked.length - 4)}` + : status.keyMasked) : t('aiProviders.card.noKey')} - {provider.hasKey && ( + {status?.hasKey && ( {t('aiProviders.card.configured')} )} @@ -535,11 +594,11 @@ function ProviderCard({

{t('aiProviders.card.fallbacks', { - count: (provider.fallbackModels?.length ?? 0) + (provider.fallbackProviderIds?.length ?? 0), + count: (account.fallbackModels?.length ?? 0) + (account.fallbackAccountIds?.length ?? 0), names: [ - ...normalizeFallbackModels(provider.fallbackModels), - ...normalizeFallbackProviderIds(provider.fallbackProviderIds) - .map((fallbackId) => allProviders.find((candidate) => candidate.id === fallbackId)?.name) + ...normalizeFallbackModels(account.fallbackModels), + ...normalizeFallbackProviderIds(account.fallbackAccountIds) + .map((fallbackId) => allProviders.find((candidate) => candidate.account.id === fallbackId)?.account.label) .filter(Boolean), ].join(', ') || t('aiProviders.card.none'), })} @@ -578,13 +637,14 @@ function ProviderCard({ } interface AddProviderDialogProps { - existingTypes: Set; + existingVendorIds: Set; + vendors: ProviderVendorInfo[]; onClose: () => void; onAdd: ( type: ProviderType, name: string, apiKey: string, - options?: { baseUrl?: string; model?: string } + options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode'] } ) => Promise; onValidateKey: ( type: string, @@ -595,7 +655,8 @@ interface AddProviderDialogProps { } function AddProviderDialog({ - existingTypes, + existingVendorIds, + vendors, onClose, onAdd, onValidateKey, @@ -626,11 +687,19 @@ function AddProviderDialog({ const showModelIdField = shouldShowProviderModelId(typeInfo, devModeUnlocked); const isOAuth = typeInfo?.isOAuth ?? false; const supportsApiKey = typeInfo?.supportsApiKey ?? false; + const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor])); + const selectedVendor = selectedType ? vendorMap.get(selectedType) : undefined; + const preferredOAuthMode = selectedVendor?.supportedAuthModes.includes('oauth_browser') + ? 'oauth_browser' + : (selectedVendor?.supportedAuthModes.includes('oauth_device') + ? 'oauth_device' + : (selectedType === 'google' ? 'oauth_browser' : null)); // Effective OAuth mode: pure OAuth providers, or dual-mode with oauth selected const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth'); - // Keep a ref to the latest values so the effect closure can access them + // Keep refs to the latest values so event handlers see the current dialog state. const latestRef = React.useRef({ selectedType, typeInfo, onAdd, onClose, t }); + const pendingOAuthRef = React.useRef<{ accountId: string; label: string } | null>(null); useEffect(() => { latestRef.current = { selectedType, typeInfo, onAdd, onClose, t }; }); @@ -642,12 +711,14 @@ function AddProviderDialog({ setOauthError(null); }; - const handleSuccess = async () => { + const handleSuccess = async (data: unknown) => { setOauthFlowing(false); setOauthData(null); setValidationError(null); const { onClose: close, t: translate } = latestRef.current; + const payload = (data as { accountId?: string } | undefined) || undefined; + const accountId = payload?.accountId || pendingOAuthRef.current?.accountId; // device-oauth.ts already saved the provider config to the backend, // including the dynamically resolved baseUrl for the region (e.g. CN vs Global). @@ -655,17 +726,17 @@ function AddProviderDialog({ // So we just fetch the latest list from the backend to update the UI. try { const store = useProviderStore.getState(); - await store.fetchProviders(); + await store.refreshProviderSnapshot(); // 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); + if (!store.defaultAccountId && accountId) { + await store.setDefaultAccount(accountId); } } catch (err) { console.error('Failed to refresh providers after OAuth:', err); } + pendingOAuthRef.current = null; close(); toast.success(translate('aiProviders.toast.added')); }; @@ -673,29 +744,28 @@ function AddProviderDialog({ const handleError = (data: unknown) => { setOauthError((data as { message: string }).message); setOauthData(null); + pendingOAuthRef.current = null; }; - window.electron.ipcRenderer.on('oauth:code', handleCode); - window.electron.ipcRenderer.on('oauth:success', handleSuccess); - window.electron.ipcRenderer.on('oauth:error', handleError); + const offCode = subscribeHostEvent('oauth:code', handleCode); + const offSuccess = subscribeHostEvent('oauth:success', handleSuccess); + const offError = subscribeHostEvent('oauth:error', handleError); return () => { - if (typeof window.electron.ipcRenderer.off === 'function') { - window.electron.ipcRenderer.off('oauth:code', handleCode); - window.electron.ipcRenderer.off('oauth:success', handleSuccess); - window.electron.ipcRenderer.off('oauth:error', handleError); - } + offCode(); + offSuccess(); + offError(); }; }, []); const handleStartOAuth = async () => { if (!selectedType) return; - if (selectedType === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { + if (selectedType === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) { toast.error(t('aiProviders.toast.minimaxConflict')); return; } - if (selectedType === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) { + if (selectedType === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) { toast.error(t('aiProviders.toast.minimaxConflict')); return; } @@ -705,10 +775,19 @@ function AddProviderDialog({ setOauthError(null); try { - await invokeIpc('provider:requestOAuth', selectedType); + const vendor = vendorMap.get(selectedType); + const supportsMultipleAccounts = vendor?.supportsMultipleAccounts ?? selectedType === 'custom'; + const accountId = supportsMultipleAccounts ? `${selectedType}-${crypto.randomUUID()}` : selectedType; + const label = name || (typeInfo?.id === 'custom' ? t('aiProviders.custom') : typeInfo?.name) || selectedType; + pendingOAuthRef.current = { accountId, label }; + await hostApiFetch('/api/providers/oauth/start', { + method: 'POST', + body: JSON.stringify({ provider: selectedType, accountId, label }), + }); } catch (e) { setOauthError(String(e)); setOauthFlowing(false); + pendingOAuthRef.current = null; } }; @@ -716,22 +795,28 @@ function AddProviderDialog({ setOauthFlowing(false); setOauthData(null); setOauthError(null); - await invokeIpc('provider:cancelOAuth'); + pendingOAuthRef.current = null; + await hostApiFetch('/api/providers/oauth/cancel', { + method: 'POST', + }); }; - // Only custom can be added multiple times. - const availableTypes = PROVIDER_TYPE_INFO.filter( - (t) => t.id === 'custom' || !existingTypes.has(t.id), - ); + const availableTypes = PROVIDER_TYPE_INFO.filter((type) => { + const vendor = vendorMap.get(type.id); + if (!vendor) { + return !existingVendorIds.has(type.id) || type.id === 'custom'; + } + return vendor.supportsMultipleAccounts || !existingVendorIds.has(type.id); + }); const handleAdd = async () => { if (!selectedType) return; - if (selectedType === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) { + if (selectedType === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) { toast.error(t('aiProviders.toast.minimaxConflict')); return; } - if (selectedType === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) { + if (selectedType === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) { toast.error(t('aiProviders.toast.minimaxConflict')); return; } @@ -772,6 +857,11 @@ function AddProviderDialog({ { baseUrl: baseUrl.trim() || undefined, model: resolveProviderModelForSave(typeInfo, modelId, devModeUnlocked), + authMode: useOAuthFlow ? (preferredOAuthMode || 'oauth_device') : selectedType === 'ollama' + ? 'local' + : (isOAuth && supportsApiKey && authMode === 'apikey') + ? 'api_key' + : vendorMap.get(selectedType)?.defaultAuthMode || 'api_key', } ); } catch { @@ -1059,4 +1149,4 @@ function AddProviderDialog({

); -} +} \ No newline at end of file diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index a8119ada6..67c683de8 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -13,6 +13,19 @@ "aiProviders": { "title": "AI Providers", "description": "Configure your AI model providers and API keys", + "overview": { + "title": "Provider Accounts", + "description": "A summary of the provider accounts and models currently configured.", + "noModelSelected": "No model selected", + "multiAccountReady": "Multi-account ready", + "singletonVendor": "Single-account vendor" + }, + "authModes": { + "apiKey": "API Key", + "oauthDevice": "OAuth Device", + "oauthBrowser": "OAuth Browser", + "local": "Local" + }, "sections": { "model": "Model Settings", "fallback": "Fallback Settings" @@ -85,6 +98,7 @@ "cancel": "Cancel", "codeCopied": "Code copied to clipboard", "authFailed": "Authentication Failed", + "browserFlowUnavailable": "Browser OAuth is not wired for this provider yet.", "tryAgain": "Try Again", "approveLogin": "Approve Login", "step1": "Copy the authorization code below.", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index ecd0817b6..d3de77893 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -13,6 +13,19 @@ "aiProviders": { "title": "AI プロバイダー", "description": "AI モデルプロバイダーと API キーを設定", + "overview": { + "title": "プロバイダーアカウント", + "description": "現在設定されているプロバイダーアカウントとモデルの概要です。", + "noModelSelected": "モデル未選択", + "multiAccountReady": "複数アカウント対応", + "singletonVendor": "単一アカウントのプロバイダー" + }, + "authModes": { + "apiKey": "API キー", + "oauthDevice": "OAuth デバイス", + "oauthBrowser": "OAuth ブラウザ", + "local": "ローカル" + }, "sections": { "model": "モデル設定", "fallback": "フォールバック設定" @@ -84,6 +97,7 @@ "cancel": "キャンセル", "codeCopied": "コードをクリップボードにコピーしました", "authFailed": "認証に失敗しました", + "browserFlowUnavailable": "このプロバイダーのブラウザ OAuth はまだ接続されていません。", "tryAgain": "再試行", "approveLogin": "ログインを承認", "step1": "以下の認証コードをコピーしてください。", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 357c04501..05f80f5b9 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -13,6 +13,19 @@ "aiProviders": { "title": "AI 模型提供商", "description": "配置 AI 模型提供商和 API 密钥", + "overview": { + "title": "提供商账户", + "description": "这里汇总当前已配置的 provider 账户与模型信息。", + "noModelSelected": "未选择模型", + "multiAccountReady": "支持多账户", + "singletonVendor": "单例提供商" + }, + "authModes": { + "apiKey": "API 密钥", + "oauthDevice": "OAuth 设备登录", + "oauthBrowser": "OAuth 浏览器登录", + "local": "本地" + }, "sections": { "model": "模型配置", "fallback": "回退配置" @@ -85,6 +98,7 @@ "cancel": "取消", "codeCopied": "代码已复制到剪贴板", "authFailed": "认证失败", + "browserFlowUnavailable": "该提供商的浏览器 OAuth 登录链路暂未接通。", "tryAgain": "重试", "approveLogin": "确认登录", "step1": "复制下方的授权码。", diff --git a/src/lib/gateway-client.ts b/src/lib/gateway-client.ts new file mode 100644 index 000000000..021164d1a --- /dev/null +++ b/src/lib/gateway-client.ts @@ -0,0 +1,239 @@ +import { hostApiFetch } from './host-api'; + +type GatewayInfo = { + wsUrl: string; + token: string; + port: number; +}; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout: ReturnType; +}; + +type GatewayEventHandler = (payload: unknown) => void; + +class GatewayBrowserClient { + private ws: WebSocket | null = null; + private connectPromise: Promise | null = null; + private gatewayInfo: GatewayInfo | null = null; + private pendingRequests = new Map(); + private eventHandlers = new Map>(); + + async connect(): Promise { + if (this.ws?.readyState === WebSocket.OPEN) { + return; + } + if (this.connectPromise) { + await this.connectPromise; + return; + } + + this.connectPromise = this.openSocket(); + try { + await this.connectPromise; + } finally { + this.connectPromise = null; + } + } + + disconnect(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + for (const [, request] of this.pendingRequests) { + clearTimeout(request.timeout); + request.reject(new Error('Gateway connection closed')); + } + this.pendingRequests.clear(); + } + + async rpc(method: string, params?: unknown, timeoutMs = 30000): Promise { + await this.connect(); + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error('Gateway socket is not connected'); + } + + const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const request = { + type: 'req', + id, + method, + params, + }; + + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Gateway RPC timeout: ${method}`)); + }, timeoutMs); + + this.pendingRequests.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timeout, + }); + this.ws!.send(JSON.stringify(request)); + }); + } + + on(eventName: string, handler: GatewayEventHandler): () => void { + const handlers = this.eventHandlers.get(eventName) || new Set(); + handlers.add(handler); + this.eventHandlers.set(eventName, handlers); + + return () => { + const current = this.eventHandlers.get(eventName); + current?.delete(handler); + if (current && current.size === 0) { + this.eventHandlers.delete(eventName); + } + }; + } + + private async openSocket(): Promise { + this.gatewayInfo = await hostApiFetch('/api/app/gateway-info'); + + await new Promise((resolve, reject) => { + const ws = new WebSocket(this.gatewayInfo!.wsUrl); + let resolved = false; + let challengeTimer: ReturnType | null = null; + + const cleanup = () => { + if (challengeTimer) { + clearTimeout(challengeTimer); + challengeTimer = null; + } + }; + + const resolveOnce = () => { + if (!resolved) { + resolved = true; + cleanup(); + resolve(); + } + }; + + const rejectOnce = (error: Error) => { + if (!resolved) { + resolved = true; + cleanup(); + reject(error); + } + }; + + ws.onopen = () => { + challengeTimer = setTimeout(() => { + rejectOnce(new Error('Gateway connect challenge timeout')); + ws.close(); + }, 10000); + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(String(event.data)) as Record; + if (message.type === 'event' && message.event === 'connect.challenge') { + const nonce = (message.payload as { nonce?: string } | undefined)?.nonce; + if (!nonce) { + rejectOnce(new Error('Gateway connect.challenge missing nonce')); + return; + } + const connectFrame = { + type: 'req', + id: `connect-${Date.now()}`, + method: 'connect', + params: { + minProtocol: 3, + maxProtocol: 3, + client: { + id: 'gateway-client', + displayName: 'ClawX', + version: '0.1.0', + platform: navigator.platform, + mode: 'ui', + }, + auth: { + token: this.gatewayInfo?.token, + }, + caps: [], + role: 'operator', + scopes: ['operator.admin'], + }, + }; + ws.send(JSON.stringify(connectFrame)); + return; + } + + if (message.type === 'res' && typeof message.id === 'string') { + if (String(message.id).startsWith('connect-')) { + this.ws = ws; + resolveOnce(); + return; + } + + const pending = this.pendingRequests.get(message.id); + if (!pending) { + return; + } + clearTimeout(pending.timeout); + this.pendingRequests.delete(message.id); + if (message.ok === false || message.error) { + const errorMessage = typeof message.error === 'object' && message.error !== null + ? String((message.error as { message?: string }).message || JSON.stringify(message.error)) + : String(message.error || 'Gateway request failed'); + pending.reject(new Error(errorMessage)); + } else { + pending.resolve(message.payload); + } + return; + } + + if (message.type === 'event' && typeof message.event === 'string') { + this.emitEvent(message.event, message.payload); + return; + } + + if (typeof message.method === 'string') { + this.emitEvent(message.method, message.params); + } + } catch (error) { + rejectOnce(error instanceof Error ? error : new Error(String(error))); + } + }; + + ws.onerror = () => { + rejectOnce(new Error('Gateway WebSocket error')); + }; + + ws.onclose = () => { + this.ws = null; + if (!resolved) { + rejectOnce(new Error('Gateway WebSocket closed before connect')); + return; + } + for (const [, request] of this.pendingRequests) { + clearTimeout(request.timeout); + request.reject(new Error('Gateway connection closed')); + } + this.pendingRequests.clear(); + this.emitEvent('__close__', null); + }; + }); + } + + private emitEvent(eventName: string, payload: unknown): void { + const handlers = this.eventHandlers.get(eventName); + if (!handlers) return; + for (const handler of handlers) { + try { + handler(payload); + } catch { + // ignore handler failures + } + } + } +} + +export const gatewayClient = new GatewayBrowserClient(); diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts new file mode 100644 index 000000000..8c8cf14ed --- /dev/null +++ b/src/lib/host-api.ts @@ -0,0 +1,42 @@ +const HOST_API_PORT = 3210; +const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`; + +async function parseResponse(response: Response): Promise { + if (!response.ok) { + let message = `${response.status} ${response.statusText}`; + try { + const payload = await response.json() as { error?: string }; + if (payload?.error) { + message = payload.error; + } + } catch { + // ignore body parse failure + } + throw new Error(message); + } + + if (response.status === 204) { + return undefined as T; + } + + return await response.json() as T; +} + +export async function hostApiFetch(path: string, init?: RequestInit): Promise { + const response = await fetch(`${HOST_API_BASE}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers || {}), + }, + }); + return parseResponse(response); +} + +export function createHostEventSource(path = '/api/events'): EventSource { + return new EventSource(`${HOST_API_BASE}${path}`); +} + +export function getHostApiBase(): string { + return HOST_API_BASE; +} diff --git a/src/lib/host-events.ts b/src/lib/host-events.ts new file mode 100644 index 000000000..35f9c3a16 --- /dev/null +++ b/src/lib/host-events.ts @@ -0,0 +1,25 @@ +import { createHostEventSource } from './host-api'; + +let eventSource: EventSource | null = null; + +function getEventSource(): EventSource { + if (!eventSource) { + eventSource = createHostEventSource(); + } + return eventSource; +} + +export function subscribeHostEvent( + eventName: string, + handler: (payload: T) => void, +): () => void { + const source = getEventSource(); + const listener = (event: Event) => { + const payload = JSON.parse((event as MessageEvent).data) as T; + handler(payload); + }; + source.addEventListener(eventName, listener); + return () => { + source.removeEventListener(eventName, listener); + }; +} diff --git a/src/lib/provider-accounts.ts b/src/lib/provider-accounts.ts new file mode 100644 index 000000000..e1bfc5e0b --- /dev/null +++ b/src/lib/provider-accounts.ts @@ -0,0 +1,122 @@ +import { hostApiFetch } from '@/lib/host-api'; +import type { + ProviderAccount, + ProviderType, + ProviderVendorInfo, + ProviderWithKeyInfo, +} from '@/lib/providers'; + +export interface ProviderSnapshot { + accounts: ProviderAccount[]; + statuses: ProviderWithKeyInfo[]; + vendors: ProviderVendorInfo[]; + defaultAccountId: string | null; +} + +export interface ProviderListItem { + account: ProviderAccount; + vendor?: ProviderVendorInfo; + status?: ProviderWithKeyInfo; +} + +export async function fetchProviderSnapshot(): Promise { + const [accounts, statuses, vendors, defaultInfo] = await Promise.all([ + hostApiFetch('/api/provider-accounts'), + hostApiFetch('/api/providers'), + hostApiFetch('/api/provider-vendors'), + hostApiFetch<{ accountId: string | null }>('/api/provider-accounts/default'), + ]); + + return { + accounts, + statuses, + vendors, + defaultAccountId: defaultInfo.accountId, + }; +} + +export function hasConfiguredCredentials( + account: ProviderAccount, + status?: ProviderWithKeyInfo, +): boolean { + if (account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' || account.authMode === 'local') { + return true; + } + return status?.hasKey ?? false; +} + +export function pickPreferredAccount( + accounts: ProviderAccount[], + defaultAccountId: string | null, + vendorId: ProviderType | string, + statusMap: Map, +): ProviderAccount | null { + const sameVendor = accounts.filter((account) => account.vendorId === vendorId); + if (sameVendor.length === 0) return null; + + return ( + (defaultAccountId ? sameVendor.find((account) => account.id === defaultAccountId) : undefined) + || sameVendor.find((account) => hasConfiguredCredentials(account, statusMap.get(account.id))) + || sameVendor[0] + ); +} + +export function buildProviderAccountId( + vendorId: ProviderType, + existingAccountId: string | null, + vendors: ProviderVendorInfo[], +): string { + if (existingAccountId) { + return existingAccountId; + } + + const vendor = vendors.find((candidate) => candidate.id === vendorId); + return vendor?.supportsMultipleAccounts ? `${vendorId}-${crypto.randomUUID()}` : vendorId; +} + +export function legacyProviderToAccount(provider: ProviderWithKeyInfo): ProviderAccount { + return { + id: provider.id, + vendorId: provider.type, + label: provider.name, + authMode: provider.type === 'ollama' ? 'local' : 'api_key', + baseUrl: provider.baseUrl, + model: provider.model, + fallbackModels: provider.fallbackModels, + fallbackAccountIds: provider.fallbackProviderIds, + enabled: provider.enabled, + isDefault: false, + createdAt: provider.createdAt, + updatedAt: provider.updatedAt, + }; +} + +export function buildProviderListItems( + accounts: ProviderAccount[], + statuses: ProviderWithKeyInfo[], + vendors: ProviderVendorInfo[], + defaultAccountId: string | null, +): ProviderListItem[] { + const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor])); + const statusMap = new Map(statuses.map((status) => [status.id, status])); + + if (accounts.length > 0) { + return accounts + .map((account) => ({ + account, + vendor: vendorMap.get(account.vendorId), + status: statusMap.get(account.id), + })) + .sort((left, right) => { + if (left.account.id === defaultAccountId) return -1; + if (right.account.id === defaultAccountId) return 1; + return right.account.updatedAt.localeCompare(left.account.updatedAt); + }); + } + + return statuses.map((status) => ({ + account: legacyProviderToAccount(status), + vendor: vendorMap.get(status.type), + status, + })); +} diff --git a/src/lib/providers.ts b/src/lib/providers.ts index b3fdfa508..36314d158 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -1,8 +1,9 @@ /** * Provider Types & UI Metadata — single source of truth for the frontend. * - * NOTE: When adding a new provider type, also update - * electron/utils/provider-registry.ts (env vars, models, configs). + * NOTE: Backend provider metadata is being refactored toward the new + * account-based registry, but the renderer still keeps a local compatibility + * layer so TypeScript project boundaries remain stable during the migration. */ export const PROVIDER_TYPES = [ @@ -21,6 +22,20 @@ export const PROVIDER_TYPES = [ ] as const; export type ProviderType = (typeof PROVIDER_TYPES)[number]; +export const BUILTIN_PROVIDER_TYPES = [ + 'anthropic', + 'openai', + 'google', + 'openrouter', + 'ark', + 'moonshot', + 'siliconflow', + 'minimax-portal', + 'minimax-portal-cn', + 'qwen-portal', + 'ollama', +] as const; + export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local'; export interface ProviderConfig { @@ -46,37 +61,80 @@ export interface ProviderTypeInfo { name: string; icon: string; placeholder: string; - /** Model brand name for display (e.g. "Claude", "GPT") */ model?: string; requiresApiKey: boolean; - /** Pre-filled base URL (for proxy/compatible providers like SiliconFlow) */ defaultBaseUrl?: string; - /** Whether the user can edit the base URL in setup */ showBaseUrl?: boolean; - /** Whether to show a Model ID input field (for providers where user picks the model) */ showModelId?: boolean; - /** Whether the Model ID input should only be shown in developer mode */ showModelIdInDevModeOnly?: boolean; - /** Default / example model ID placeholder */ modelIdPlaceholder?: string; - /** Default model ID to pre-fill */ defaultModelId?: string; - /** Whether this provider uses OAuth device flow instead of an API key */ isOAuth?: boolean; - /** Whether this provider also accepts a direct API key (in addition to OAuth) */ supportsApiKey?: boolean; - /** URL where users can apply for the API Key */ apiKeyUrl?: string; } +export type ProviderAuthMode = + | 'api_key' + | 'oauth_device' + | 'oauth_browser' + | 'local'; + +export type ProviderVendorCategory = + | 'official' + | 'compatible' + | 'local' + | 'custom'; + +export interface ProviderVendorInfo extends ProviderTypeInfo { + category: ProviderVendorCategory; + envVar?: string; + supportedAuthModes: ProviderAuthMode[]; + defaultAuthMode: ProviderAuthMode; + supportsMultipleAccounts: boolean; +} + +export interface ProviderAccount { + id: string; + vendorId: ProviderType; + label: string; + authMode: ProviderAuthMode; + baseUrl?: string; + apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages'; + model?: string; + fallbackModels?: string[]; + fallbackAccountIds?: string[]; + enabled: boolean; + isDefault: boolean; + metadata?: { + region?: string; + email?: string; + resourceUrl?: string; + customModels?: string[]; + }; + createdAt: string; + updatedAt: string; +} + import { providerIcons } from '@/assets/providers'; /** All supported provider types with UI metadata */ export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [ { id: 'anthropic', name: 'Anthropic', icon: '🤖', placeholder: 'sk-ant-api03-...', model: 'Claude', requiresApiKey: true }, { id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...', model: 'GPT', requiresApiKey: true }, - { id: 'google', name: 'Google', icon: '🔷', placeholder: 'AIza...', model: 'Gemini', requiresApiKey: true }, - { id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'anthropic/claude-opus-4.6', defaultModelId: 'anthropic/claude-opus-4.6' }, + { + id: 'google', + name: 'Google', + icon: '🔷', + placeholder: 'AIza...', + model: 'Gemini', + requiresApiKey: true, + isOAuth: true, + supportsApiKey: true, + defaultModelId: 'gemini-3.1-pro-preview', + apiKeyUrl: 'https://aistudio.google.com/app/apikey', + }, + { id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, modelIdPlaceholder: 'anthropic/claude-opus-4.6', defaultModelId: 'anthropic/claude-opus-4.6' }, { id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx' }, { id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' }, { id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3' }, diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index 0c007ebdf..63463a3e5 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -2,7 +2,7 @@ * Channels Page * Manage messaging channel connections with configuration UI */ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Plus, Radio, @@ -33,6 +33,9 @@ import { useChannelsStore } from '@/stores/channels'; import { useGatewayStore } from '@/stores/gateway'; import { StatusBadge, type Status } from '@/components/common/StatusBadge'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; +import { hostApiFetch } from '@/lib/host-api'; +import { subscribeHostEvent } from '@/lib/host-events'; +import { invokeIpc } from '@/lib/api-client'; import { CHANNEL_ICONS, CHANNEL_NAMES, @@ -45,7 +48,6 @@ import { } from '@/types/channel'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; -import { invokeIpc } from '@/lib/api-client'; export function Channels() { const { t } = useTranslation('channels'); @@ -55,26 +57,20 @@ export function Channels() { const [showAddDialog, setShowAddDialog] = useState(false); const [selectedChannelType, setSelectedChannelType] = useState(null); const [configuredTypes, setConfiguredTypes] = useState([]); - const [channelSnapshot, setChannelSnapshot] = useState([]); - const [configuredTypesSnapshot, setConfiguredTypesSnapshot] = useState([]); const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null); - const [refreshing, setRefreshing] = useState(false); - const [showGatewayWarning, setShowGatewayWarning] = useState(false); - const refreshDebounceRef = useRef | null>(null); - const lastGatewayStateRef = useRef(gatewayStatus.state); // Fetch channels on mount useEffect(() => { - void fetchChannels({ probe: false }); + fetchChannels(); }, [fetchChannels]); // Fetch configured channel types from config file const fetchConfiguredTypes = useCallback(async () => { try { - const result = await invokeIpc('channel:listConfigured') as { + const result = await hostApiFetch<{ success: boolean; channels?: string[]; - }; + }>('/api/channels/configured'); if (result.success && result.channels) { setConfiguredTypes(result.channels); } @@ -84,86 +80,29 @@ export function Channels() { }, []); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect void fetchConfiguredTypes(); }, [fetchConfiguredTypes]); useEffect(() => { - const unsubscribe = window.electron.ipcRenderer.on('gateway:channel-status', () => { - if (refreshDebounceRef.current) { - clearTimeout(refreshDebounceRef.current); - } - refreshDebounceRef.current = setTimeout(() => { - void fetchChannels({ probe: false, silent: true }); - void fetchConfiguredTypes(); - }, 300); + const unsubscribe = subscribeHostEvent('gateway:channel-status', () => { + fetchChannels(); + fetchConfiguredTypes(); }); return () => { - if (refreshDebounceRef.current) { - clearTimeout(refreshDebounceRef.current); - refreshDebounceRef.current = null; - } if (typeof unsubscribe === 'function') { unsubscribe(); } }; }, [fetchChannels, fetchConfiguredTypes]); - useEffect(() => { - if (gatewayStatus.state === 'running') { - setChannelSnapshot(channels); - setConfiguredTypesSnapshot(configuredTypes); - } - }, [gatewayStatus.state, channels, configuredTypes]); - - useEffect(() => { - const previousState = lastGatewayStateRef.current; - const currentState = gatewayStatus.state; - const justReconnected = - currentState === 'running' && - previousState !== 'running'; - lastGatewayStateRef.current = currentState; - - if (!justReconnected) return; - void fetchChannels({ probe: false, silent: true }); - void fetchConfiguredTypes(); - }, [gatewayStatus.state, fetchChannels, fetchConfiguredTypes]); - - // Delay warning to avoid flicker during expected short reload/restart windows. - useEffect(() => { - const shouldWarn = gatewayStatus.state === 'stopped' || gatewayStatus.state === 'error'; - const timer = setTimeout(() => { - setShowGatewayWarning(shouldWarn); - }, shouldWarn ? 1800 : 0); - return () => clearTimeout(timer); - }, [gatewayStatus.state]); - // Get channel types to display const displayedChannelTypes = getPrimaryChannels(); - const isGatewayTransitioning = - gatewayStatus.state === 'starting' || gatewayStatus.state === 'reconnecting'; - const channelsForView = - isGatewayTransitioning && channels.length === 0 ? channelSnapshot : channels; - const configuredTypesForView = - isGatewayTransitioning && configuredTypes.length === 0 ? configuredTypesSnapshot : configuredTypes; - - // Single source of truth for configured status across cards, stats and badges. - const configuredTypeSet = useMemo(() => { - const set = new Set(configuredTypesForView); - if (set.size === 0 && channelsForView.length > 0) { - channelsForView.forEach((channel) => set.add(channel.type)); - } - return set; - }, [configuredTypesForView, channelsForView]); - - const configuredChannels = useMemo( - () => channelsForView.filter((channel) => configuredTypeSet.has(channel.type)), - [channelsForView, configuredTypeSet] - ); // Connected/disconnected channel counts - const connectedCount = configuredChannels.filter((c) => c.status === 'connected').length; + const connectedCount = channels.filter((c) => c.status === 'connected').length; - if (loading && channels.length === 0) { + if (loading) { return (
@@ -182,20 +121,8 @@ export function Channels() {

-
-

{configuredChannels.length}

+

{channels.length}

{t('stats.total')}

@@ -240,7 +167,7 @@ export function Channels() {
-

{configuredChannels.length - connectedCount}

+

{channels.length - connectedCount}

{t('stats.disconnected')}

@@ -249,7 +176,7 @@ export function Channels() { {/* Gateway Warning */} - {showGatewayWarning && ( + {gatewayStatus.state !== 'running' && ( @@ -270,7 +197,7 @@ export function Channels() { )} {/* Configured Channels */} - {configuredChannels.length > 0 && ( + {channels.length > 0 && ( {t('configured')} @@ -278,7 +205,7 @@ export function Channels() {
- {configuredChannels.map((channel) => ( + {channels.map((channel) => ( {displayedChannelTypes.map((type) => { const meta = CHANNEL_META[type]; - const isConfigured = configuredTypeSet.has(type); + const isConfigured = configuredTypes.includes(type); return (