import { EventEmitter } from 'events'; import { BrowserWindow, shell } from 'electron'; import { logger } from './logger'; import { loginOpenAICodexOAuth, type OpenAICodexOAuthCredentials } from './openai-codex-oauth'; import { getProviderService } from '../services/providers/provider-service'; import { getSecretStore } from '../services/secrets/secret-store'; import { ensureOpenClawProviderAgentRuntimePins, saveOAuthTokenToOpenClaw, setOpenClawDefaultModel, } from './openclaw-auth'; // Google was removed: OpenClaw's `google-gemini-cli` OAuth integration is an // unofficial third-party flow that requires the `gemini` CLI binary to be on // PATH and ships with explicit "use at your own risk" warnings about Google // account suspensions. ClawX does not bundle that binary, so the only // browser-OAuth provider we currently expose end-to-end is OpenAI Codex. export type BrowserOAuthProviderType = 'openai'; const OPENAI_RUNTIME_PROVIDER_ID = 'openai-codex'; const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.5'; 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; private pendingManualCodeResolve: ((value: string) => void) | null = null; private pendingManualCodeReject: ((reason?: unknown) => void) | 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 }); // OpenAI flow may switch to manual callback mode; keep start API non-blocking. void this.executeFlow(provider); return true; } private async executeFlow(provider: BrowserOAuthProviderType): Promise { try { const token = await loginOpenAICodexOAuth({ openUrl: async (url) => { await shell.openExternal(url); }, onProgress: (message) => logger.info(`[BrowserOAuth] ${message}`), onManualCodeRequired: ({ authorizationUrl, reason }) => { const message = reason === 'port_in_use' ? 'OpenAI OAuth callback port 1455 is in use. Complete sign-in, then paste the final callback URL or code.' : 'OpenAI OAuth callback timed out. Paste the final callback URL or code to continue.'; const payload = { provider, mode: 'manual' as const, authorizationUrl, message, }; this.emit('oauth:code', payload); if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.webContents.send('oauth:code', payload); } }, onManualCodeInput: async () => { return await new Promise((resolve, reject) => { this.pendingManualCodeResolve = resolve; this.pendingManualCodeReject = reject; }); }, }); await this.onSuccess(provider, token); } catch (error) { if (!this.active) { return; } 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; this.pendingManualCodeResolve = null; this.pendingManualCodeReject = null; } } async stopFlow(): Promise { this.active = false; this.activeProvider = null; this.activeAccountId = null; this.activeLabel = null; if (this.pendingManualCodeReject) { this.pendingManualCodeReject(new Error('OAuth flow cancelled')); } this.pendingManualCodeResolve = null; this.pendingManualCodeReject = null; logger.info('[BrowserOAuth] Flow explicitly stopped'); } submitManualCode(code: string): boolean { const value = code.trim(); if (!value || !this.pendingManualCodeResolve) { return false; } this.pendingManualCodeResolve(value); this.pendingManualCodeResolve = null; this.pendingManualCodeReject = null; return true; } private async onSuccess( providerType: BrowserOAuthProviderType, token: OpenAICodexOAuthCredentials, ) { const accountId = this.activeAccountId || providerType; const accountLabel = this.activeLabel; this.active = false; this.activeProvider = null; this.activeAccountId = null; this.activeLabel = null; this.pendingManualCodeResolve = null; this.pendingManualCodeReject = null; logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`); const providerService = getProviderService(); const existing = await providerService.getAccount(accountId); const runtimeProviderId = OPENAI_RUNTIME_PROVIDER_ID; const defaultModel = OPENAI_OAUTH_DEFAULT_MODEL; const accountLabelDefault = 'OpenAI Codex'; const oauthTokenEmail = typeof token.email === 'string' ? token.email : undefined; const oauthTokenSubject = typeof token.accountId === 'string' ? token.accountId : undefined; // OpenAI OAuth uses openai-codex/* runtime; existing openai/* refs are incompatible. const normalizedExistingModel = (() => { const value = existing?.model?.trim(); if (!value) return undefined; if (value.startsWith('openai/')) return undefined; if (value.startsWith('openai-codex/')) return value.split('/').pop(); return value.includes('/') ? value.split('/').pop() : value; })(); const nextAccount = await providerService.createAccount({ id: accountId, vendorId: providerType, label: accountLabel || existing?.label || accountLabelDefault, authMode: 'oauth_browser', baseUrl: existing?.baseUrl, apiProtocol: existing?.apiProtocol, model: normalizedExistingModel || defaultModel, fallbackModels: existing?.fallbackModels, fallbackAccountIds: existing?.fallbackAccountIds, enabled: existing?.enabled ?? true, isDefault: existing?.isDefault ?? false, metadata: { ...existing?.metadata, email: oauthTokenEmail, resourceUrl: runtimeProviderId, }, 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: oauthTokenEmail, subject: oauthTokenSubject, }); await saveOAuthTokenToOpenClaw(runtimeProviderId, { access: token.access, refresh: token.refresh, expires: token.expires, email: oauthTokenEmail, projectId: oauthTokenSubject, }); const modelId = normalizedExistingModel || defaultModel; const modelRef = `${runtimeProviderId}/${modelId}`; const fallbackModelRefs = (nextAccount.fallbackModels ?? []) .map((fallback) => fallback.trim()) .filter(Boolean) .map((fallback) => ( fallback.startsWith(`${runtimeProviderId}/`) ? fallback : `${runtimeProviderId}/${fallback}` )); try { await setOpenClawDefaultModel(runtimeProviderId, modelRef, fallbackModelRefs); await ensureOpenClawProviderAgentRuntimePins(); logger.info(`[BrowserOAuth] Registered ${runtimeProviderId} in openclaw.json (default model: ${modelRef})`); } catch (err) { logger.warn('[BrowserOAuth] Failed to register OpenAI OAuth provider in openclaw.json:', err); throw err; } 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();