feat(provider): add OpenAI Codex browser OAuth flow (#398)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
880995af19
commit
31e80f256b
@@ -110,7 +110,7 @@ AIタスクを自動的に実行するようスケジュール設定できます
|
|||||||
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。
|
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。
|
||||||
|
|
||||||
### 🔐 セキュアなプロバイダー統合
|
### 🔐 セキュアなプロバイダー統合
|
||||||
複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。
|
複数のAIプロバイダー(OpenAI、Anthropicなど)に接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuth(Codex サブスクリプション)の両方に対応しています。
|
||||||
|
|
||||||
### 🌙 アダプティブテーマ
|
### 🌙 アダプティブテーマ
|
||||||
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。
|
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。
|
||||||
@@ -149,7 +149,7 @@ pnpm dev
|
|||||||
ClawXを初めて起動すると、**セットアップウィザード**が以下の手順をガイドします:
|
ClawXを初めて起動すると、**セットアップウィザード**が以下の手順をガイドします:
|
||||||
|
|
||||||
1. **言語と地域** – 使用する言語・地域の設定
|
1. **言語と地域** – 使用する言語・地域の設定
|
||||||
2. **AIプロバイダー** – サポートされているプロバイダーのAPIキーを入力
|
2. **AIプロバイダー** – APIキーまたは OAuth(ブラウザ/デバイスログイン対応プロバイダー)で追加
|
||||||
3. **スキルバンドル** – 一般的なユースケース向けの事前設定スキルを選択
|
3. **スキルバンドル** – 一般的なユースケース向けの事前設定スキルを選択
|
||||||
4. **検証** – メインインターフェースに入る前に設定をテスト
|
4. **検証** – メインインターフェースに入る前に設定をテスト
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ Schedule AI tasks to run automatically. Define triggers, set intervals, and let
|
|||||||
Extend your AI agents with pre-built skills. Browse, install, and manage skills through the integrated skill panel—no package managers required.
|
Extend your AI agents with pre-built skills. Browse, install, and manage skills through the integrated skill panel—no package managers required.
|
||||||
|
|
||||||
### 🔐 Secure Provider Integration
|
### 🔐 Secure Provider Integration
|
||||||
Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain.
|
Connect to multiple AI providers (OpenAI, Anthropic, and more) with credentials stored securely in your system's native keychain. OpenAI supports both API key and browser OAuth (Codex subscription) sign-in.
|
||||||
|
|
||||||
### 🌙 Adaptive Theming
|
### 🌙 Adaptive Theming
|
||||||
Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically.
|
Light mode, dark mode, or system-synchronized themes. ClawX adapts to your preferences automatically.
|
||||||
@@ -149,7 +149,7 @@ pnpm dev
|
|||||||
When you launch ClawX for the first time, the **Setup Wizard** will guide you through:
|
When you launch ClawX for the first time, the **Setup Wizard** will guide you through:
|
||||||
|
|
||||||
1. **Language & Region** – Configure your preferred locale
|
1. **Language & Region** – Configure your preferred locale
|
||||||
2. **AI Provider** – Enter your API keys for supported providers
|
2. **AI Provider** – Add providers with API keys or OAuth (for providers that support browser/device login)
|
||||||
3. **Skill Bundles** – Select pre-configured skills for common use cases
|
3. **Skill Bundles** – Select pre-configured skills for common use cases
|
||||||
4. **Verification** – Test your configuration before entering the main interface
|
4. **Verification** – Test your configuration before entering the main interface
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
|||||||
通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。
|
通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。
|
||||||
|
|
||||||
### 🔐 安全的供应商集成
|
### 🔐 安全的供应商集成
|
||||||
连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。
|
连接多个 AI 供应商(OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuth(Codex 订阅)登录。
|
||||||
|
|
||||||
### 🌙 自适应主题
|
### 🌙 自适应主题
|
||||||
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
|
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
|
||||||
@@ -150,7 +150,7 @@ pnpm dev
|
|||||||
首次启动 ClawX 时,**设置向导** 将引导你完成以下步骤:
|
首次启动 ClawX 时,**设置向导** 将引导你完成以下步骤:
|
||||||
|
|
||||||
1. **语言与区域** – 配置你的首选语言和地区
|
1. **语言与区域** – 配置你的首选语言和地区
|
||||||
2. **AI 供应商** – 输入所支持供应商的 API 密钥
|
2. **AI 供应商** – 通过 API 密钥或 OAuth(支持浏览器/设备登录的供应商)添加账号
|
||||||
3. **技能包** – 选择适用于常见场景的预配置技能
|
3. **技能包** – 选择适用于常见场景的预配置技能
|
||||||
4. **验证** – 在进入主界面前测试你的配置
|
4. **验证** – 在进入主界面前测试你的配置
|
||||||
|
|
||||||
|
|||||||
@@ -107,8 +107,10 @@ export async function handleProviderRoutes(
|
|||||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||||
try {
|
try {
|
||||||
const existing = await providerService.getAccount(accountId);
|
const existing = await providerService.getAccount(accountId);
|
||||||
const runtimeProviderKey = existing?.vendorId === 'google' && existing.authMode === 'oauth_browser'
|
const runtimeProviderKey = existing?.authMode === 'oauth_browser'
|
||||||
? 'google-gemini-cli'
|
? (existing.vendorId === 'google'
|
||||||
|
? 'google-gemini-cli'
|
||||||
|
: (existing.vendorId === 'openai' ? 'openai-codex' : undefined))
|
||||||
: undefined;
|
: undefined;
|
||||||
if (url.searchParams.get('apiKeyOnly') === '1') {
|
if (url.searchParams.get('apiKeyOnly') === '1') {
|
||||||
await providerService.deleteLegacyProviderApiKey(accountId);
|
await providerService.deleteLegacyProviderApiKey(accountId);
|
||||||
@@ -184,7 +186,7 @@ export async function handleProviderRoutes(
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}>(req);
|
}>(req);
|
||||||
if (body.provider === 'google') {
|
if (body.provider === 'google' || body.provider === 'openai') {
|
||||||
await browserOAuthManager.startFlow(body.provider, {
|
await browserOAuthManager.startFlow(body.provider, {
|
||||||
accountId: body.accountId,
|
accountId: body.accountId,
|
||||||
label: body.label,
|
label: body.label,
|
||||||
@@ -214,6 +216,22 @@ export async function handleProviderRoutes(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/providers/oauth/submit' && req.method === 'POST') {
|
||||||
|
logLegacyProviderRoute('POST /api/providers/oauth/submit');
|
||||||
|
try {
|
||||||
|
const body = await parseJsonBody<{ code: string }>(req);
|
||||||
|
const accepted = browserOAuthManager.submitManualCode(body.code || '');
|
||||||
|
if (!accepted) {
|
||||||
|
sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
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') {
|
if (url.pathname === '/api/providers' && req.method === 'POST') {
|
||||||
logLegacyProviderRoute('POST /api/providers');
|
logLegacyProviderRoute('POST /api/providers');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -283,6 +283,10 @@ async function initialize(): Promise<void> {
|
|||||||
hostEventBus.emit('oauth:start', payload);
|
hostEventBus.emit('oauth:start', payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
browserOAuthManager.on('oauth:code', (payload) => {
|
||||||
|
hostEventBus.emit('oauth:code', payload);
|
||||||
|
});
|
||||||
|
|
||||||
browserOAuthManager.on('oauth:success', (payload) => {
|
browserOAuthManager.on('oauth:success', (payload) => {
|
||||||
hostEventBus.emit('oauth:success', { ...payload, success: true });
|
hostEventBus.emit('oauth:success', { ...payload, success: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1758,7 +1758,7 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void {
|
|||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
logger.info(`provider:requestOAuth for ${provider}`);
|
logger.info(`provider:requestOAuth for ${provider}`);
|
||||||
if (provider === 'google') {
|
if (provider === 'google' || provider === 'openai') {
|
||||||
await browserOAuthManager.startFlow(provider, options);
|
await browserOAuthManager.startFlow(provider, options);
|
||||||
} else {
|
} else {
|
||||||
await deviceOAuthManager.startFlow(provider, region, options);
|
await deviceOAuthManager.startFlow(provider, region, options);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { logger } from '../../utils/logger';
|
|||||||
|
|
||||||
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
|
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
|
||||||
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
|
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
|
||||||
|
const OPENAI_OAUTH_RUNTIME_PROVIDER = 'openai-codex';
|
||||||
|
const OPENAI_OAUTH_DEFAULT_MODEL_REF = `${OPENAI_OAUTH_RUNTIME_PROVIDER}/gpt-5.3-codex`;
|
||||||
|
|
||||||
type RuntimeProviderSyncContext = {
|
type RuntimeProviderSyncContext = {
|
||||||
runtimeProviderKey: string;
|
runtimeProviderKey: string;
|
||||||
@@ -53,20 +55,35 @@ export function getOpenClawProviderKey(type: string, providerId: string): string
|
|||||||
|
|
||||||
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
|
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
|
||||||
const account = await getProviderAccount(config.id);
|
const account = await getProviderAccount(config.id);
|
||||||
if (config.type === 'google' && account?.authMode === 'oauth_browser') {
|
if (account?.authMode === 'oauth_browser') {
|
||||||
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
|
if (config.type === 'google') {
|
||||||
|
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
|
||||||
|
}
|
||||||
|
if (config.type === 'openai') {
|
||||||
|
return OPENAI_OAUTH_RUNTIME_PROVIDER;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return getOpenClawProviderKey(config.type, config.id);
|
return getOpenClawProviderKey(config.type, config.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isGoogleBrowserOAuthProvider(config: ProviderConfig): Promise<boolean> {
|
async function getBrowserOAuthRuntimeProvider(config: ProviderConfig): Promise<string | null> {
|
||||||
const account = await getProviderAccount(config.id);
|
const account = await getProviderAccount(config.id);
|
||||||
if (config.type !== 'google' || account?.authMode !== 'oauth_browser') {
|
if (account?.authMode !== 'oauth_browser') {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const secret = await getProviderSecret(config.id);
|
const secret = await getProviderSecret(config.id);
|
||||||
return secret?.type === 'oauth';
|
if (secret?.type !== 'oauth') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === 'google') {
|
||||||
|
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
|
||||||
|
}
|
||||||
|
if (config.type === 'openai') {
|
||||||
|
return OPENAI_OAUTH_RUNTIME_PROVIDER;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderModelRef(config: ProviderConfig): string | undefined {
|
export function getProviderModelRef(config: ProviderConfig): string | undefined {
|
||||||
@@ -396,8 +413,8 @@ export async function syncDefaultProviderToRuntime(
|
|||||||
const providerKey = await getApiKey(providerId);
|
const providerKey = await getApiKey(providerId);
|
||||||
const fallbackModels = await getProviderFallbackModelRefs(provider);
|
const fallbackModels = await getProviderFallbackModelRefs(provider);
|
||||||
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||||
const isGoogleOAuthProvider = await isGoogleBrowserOAuthProvider(provider);
|
const browserOAuthRuntimeProvider = await getBrowserOAuthRuntimeProvider(provider);
|
||||||
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || isGoogleOAuthProvider;
|
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || Boolean(browserOAuthRuntimeProvider);
|
||||||
|
|
||||||
if (!isOAuthProvider) {
|
if (!isOAuthProvider) {
|
||||||
const modelOverride = provider.model
|
const modelOverride = provider.model
|
||||||
@@ -424,10 +441,10 @@ export async function syncDefaultProviderToRuntime(
|
|||||||
await saveProviderKeyToOpenClaw(ock, providerKey);
|
await saveProviderKeyToOpenClaw(ock, providerKey);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isGoogleOAuthProvider) {
|
if (browserOAuthRuntimeProvider) {
|
||||||
const secret = await getProviderSecret(provider.id);
|
const secret = await getProviderSecret(provider.id);
|
||||||
if (secret?.type === 'oauth') {
|
if (secret?.type === 'oauth') {
|
||||||
await saveOAuthTokenToOpenClaw(GOOGLE_OAUTH_RUNTIME_PROVIDER, {
|
await saveOAuthTokenToOpenClaw(browserOAuthRuntimeProvider, {
|
||||||
access: secret.accessToken,
|
access: secret.accessToken,
|
||||||
refresh: secret.refreshToken,
|
refresh: secret.refreshToken,
|
||||||
expires: secret.expiresAt,
|
expires: secret.expiresAt,
|
||||||
@@ -436,17 +453,20 @@ export async function syncDefaultProviderToRuntime(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultModelRef = browserOAuthRuntimeProvider === GOOGLE_OAUTH_RUNTIME_PROVIDER
|
||||||
|
? GOOGLE_OAUTH_DEFAULT_MODEL_REF
|
||||||
|
: OPENAI_OAUTH_DEFAULT_MODEL_REF;
|
||||||
const modelOverride = provider.model
|
const modelOverride = provider.model
|
||||||
? (provider.model.startsWith(`${GOOGLE_OAUTH_RUNTIME_PROVIDER}/`)
|
? (provider.model.startsWith(`${browserOAuthRuntimeProvider}/`)
|
||||||
? provider.model
|
? provider.model
|
||||||
: `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/${provider.model}`)
|
: `${browserOAuthRuntimeProvider}/${provider.model}`)
|
||||||
: GOOGLE_OAUTH_DEFAULT_MODEL_REF;
|
: defaultModelRef;
|
||||||
|
|
||||||
await setOpenClawDefaultModel(GOOGLE_OAUTH_RUNTIME_PROVIDER, modelOverride, fallbackModels);
|
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
|
||||||
logger.info(`Configured openclaw.json for Google browser OAuth provider "${provider.id}"`);
|
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
|
||||||
scheduleGatewayRestart(
|
scheduleGatewayRestart(
|
||||||
gatewayManager,
|
gatewayManager,
|
||||||
`Scheduling Gateway restart after provider switch to "${GOOGLE_OAUTH_RUNTIME_PROVIDER}"`,
|
`Scheduling Gateway restart after provider switch to "${browserOAuthRuntimeProvider}"`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
|
|||||||
category: 'official',
|
category: 'official',
|
||||||
envVar: 'OPENAI_API_KEY',
|
envVar: 'OPENAI_API_KEY',
|
||||||
defaultModelId: 'gpt-5.2',
|
defaultModelId: 'gpt-5.2',
|
||||||
supportedAuthModes: ['api_key'],
|
isOAuth: true,
|
||||||
|
supportsApiKey: true,
|
||||||
|
supportedAuthModes: ['api_key', 'oauth_browser'],
|
||||||
defaultAuthMode: 'api_key',
|
defaultAuthMode: 'api_key',
|
||||||
supportsMultipleAccounts: true,
|
supportsMultipleAccounts: true,
|
||||||
providerConfig: {
|
providerConfig: {
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import { EventEmitter } from 'events';
|
|||||||
import { BrowserWindow, shell } from 'electron';
|
import { BrowserWindow, shell } from 'electron';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { loginGeminiCliOAuth, type GeminiCliOAuthCredentials } from './gemini-cli-oauth';
|
import { loginGeminiCliOAuth, type GeminiCliOAuthCredentials } from './gemini-cli-oauth';
|
||||||
|
import { loginOpenAICodexOAuth, type OpenAICodexOAuthCredentials } from './openai-codex-oauth';
|
||||||
import { getProviderService } from '../services/providers/provider-service';
|
import { getProviderService } from '../services/providers/provider-service';
|
||||||
import { getSecretStore } from '../services/secrets/secret-store';
|
import { getSecretStore } from '../services/secrets/secret-store';
|
||||||
import { saveOAuthTokenToOpenClaw } from './openclaw-auth';
|
import { saveOAuthTokenToOpenClaw } from './openclaw-auth';
|
||||||
|
|
||||||
export type BrowserOAuthProviderType = 'google';
|
export type BrowserOAuthProviderType = 'google' | 'openai';
|
||||||
|
|
||||||
const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli';
|
const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli';
|
||||||
const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview';
|
const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview';
|
||||||
|
const OPENAI_RUNTIME_PROVIDER_ID = 'openai-codex';
|
||||||
|
const OPENAI_OAUTH_DEFAULT_MODEL = 'gpt-5.3-codex';
|
||||||
|
|
||||||
class BrowserOAuthManager extends EventEmitter {
|
class BrowserOAuthManager extends EventEmitter {
|
||||||
private activeProvider: BrowserOAuthProviderType | null = null;
|
private activeProvider: BrowserOAuthProviderType | null = null;
|
||||||
@@ -17,6 +20,8 @@ class BrowserOAuthManager extends EventEmitter {
|
|||||||
private activeLabel: string | null = null;
|
private activeLabel: string | null = null;
|
||||||
private active = false;
|
private active = false;
|
||||||
private mainWindow: BrowserWindow | null = null;
|
private mainWindow: BrowserWindow | null = null;
|
||||||
|
private pendingManualCodeResolve: ((value: string) => void) | null = null;
|
||||||
|
private pendingManualCodeReject: ((reason?: unknown) => void) | null = null;
|
||||||
|
|
||||||
setWindow(window: BrowserWindow) {
|
setWindow(window: BrowserWindow) {
|
||||||
this.mainWindow = window;
|
this.mainWindow = window;
|
||||||
@@ -36,38 +41,72 @@ class BrowserOAuthManager extends EventEmitter {
|
|||||||
this.activeLabel = options?.label || null;
|
this.activeLabel = options?.label || null;
|
||||||
this.emit('oauth:start', { provider, accountId: this.activeAccountId });
|
this.emit('oauth:start', { provider, accountId: this.activeAccountId });
|
||||||
|
|
||||||
try {
|
if (provider === 'openai') {
|
||||||
if (provider !== 'google') {
|
// OpenAI flow may switch to manual callback mode; keep start API non-blocking.
|
||||||
throw new Error(`Unsupported browser OAuth provider type: ${provider}`);
|
void this.executeFlow(provider);
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const token = await loginGeminiCliOAuth({
|
await this.executeFlow(provider);
|
||||||
isRemote: false,
|
return true;
|
||||||
openUrl: async (url) => {
|
}
|
||||||
await shell.openExternal(url);
|
|
||||||
},
|
private async executeFlow(provider: BrowserOAuthProviderType): Promise<void> {
|
||||||
log: (message) => logger.info(`[BrowserOAuth] ${message}`),
|
try {
|
||||||
note: async (message, title) => {
|
const token = provider === 'google'
|
||||||
logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`);
|
? await loginGeminiCliOAuth({
|
||||||
},
|
isRemote: false,
|
||||||
prompt: async () => {
|
openUrl: async (url) => {
|
||||||
throw new Error('Manual browser OAuth fallback is not implemented in ClawX yet.');
|
await shell.openExternal(url);
|
||||||
},
|
},
|
||||||
progress: {
|
log: (message) => logger.info(`[BrowserOAuth] ${message}`),
|
||||||
update: (message) => logger.info(`[BrowserOAuth] ${message}`),
|
note: async (message, title) => {
|
||||||
stop: (message) => {
|
logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`);
|
||||||
if (message) {
|
},
|
||||||
logger.info(`[BrowserOAuth] ${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 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<string>((resolve, reject) => {
|
||||||
|
this.pendingManualCodeResolve = resolve;
|
||||||
|
this.pendingManualCodeReject = reject;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await this.onSuccess(provider, token);
|
await this.onSuccess(provider, token);
|
||||||
return true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.active) {
|
if (!this.active) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
logger.error(`[BrowserOAuth] Flow error for ${provider}:`, error);
|
logger.error(`[BrowserOAuth] Flow error for ${provider}:`, error);
|
||||||
this.emitError(error instanceof Error ? error.message : String(error));
|
this.emitError(error instanceof Error ? error.message : String(error));
|
||||||
@@ -75,7 +114,8 @@ class BrowserOAuthManager extends EventEmitter {
|
|||||||
this.activeProvider = null;
|
this.activeProvider = null;
|
||||||
this.activeAccountId = null;
|
this.activeAccountId = null;
|
||||||
this.activeLabel = null;
|
this.activeLabel = null;
|
||||||
return false;
|
this.pendingManualCodeResolve = null;
|
||||||
|
this.pendingManualCodeReject = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,12 +124,28 @@ class BrowserOAuthManager extends EventEmitter {
|
|||||||
this.activeProvider = null;
|
this.activeProvider = null;
|
||||||
this.activeAccountId = null;
|
this.activeAccountId = null;
|
||||||
this.activeLabel = 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');
|
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(
|
private async onSuccess(
|
||||||
providerType: BrowserOAuthProviderType,
|
providerType: BrowserOAuthProviderType,
|
||||||
token: GeminiCliOAuthCredentials,
|
token: GeminiCliOAuthCredentials | OpenAICodexOAuthCredentials,
|
||||||
) {
|
) {
|
||||||
const accountId = this.activeAccountId || providerType;
|
const accountId = this.activeAccountId || providerType;
|
||||||
const accountLabel = this.activeLabel;
|
const accountLabel = this.activeLabel;
|
||||||
@@ -97,26 +153,49 @@ class BrowserOAuthManager extends EventEmitter {
|
|||||||
this.activeProvider = null;
|
this.activeProvider = null;
|
||||||
this.activeAccountId = null;
|
this.activeAccountId = null;
|
||||||
this.activeLabel = null;
|
this.activeLabel = null;
|
||||||
|
this.pendingManualCodeResolve = null;
|
||||||
|
this.pendingManualCodeReject = null;
|
||||||
logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`);
|
logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`);
|
||||||
|
|
||||||
const providerService = getProviderService();
|
const providerService = getProviderService();
|
||||||
const existing = await providerService.getAccount(accountId);
|
const existing = await providerService.getAccount(accountId);
|
||||||
|
const isGoogle = providerType === 'google';
|
||||||
|
const runtimeProviderId = isGoogle ? GOOGLE_RUNTIME_PROVIDER_ID : OPENAI_RUNTIME_PROVIDER_ID;
|
||||||
|
const defaultModel = isGoogle ? GOOGLE_OAUTH_DEFAULT_MODEL : OPENAI_OAUTH_DEFAULT_MODEL;
|
||||||
|
const accountLabelDefault = isGoogle ? 'Google Gemini' : 'OpenAI Codex';
|
||||||
|
const oauthTokenEmail = 'email' in token && typeof token.email === 'string' ? token.email : undefined;
|
||||||
|
const oauthTokenSubject = 'projectId' in token && typeof token.projectId === 'string'
|
||||||
|
? token.projectId
|
||||||
|
: ('accountId' in token && typeof token.accountId === 'string' ? token.accountId : undefined);
|
||||||
|
|
||||||
|
const normalizedExistingModel = (() => {
|
||||||
|
const value = existing?.model?.trim();
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (isGoogle) {
|
||||||
|
return value.includes('/') ? value.split('/').pop() : value;
|
||||||
|
}
|
||||||
|
// OpenAI OAuth uses openai-codex/* runtime; existing openai/* refs are incompatible.
|
||||||
|
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({
|
const nextAccount = await providerService.createAccount({
|
||||||
id: accountId,
|
id: accountId,
|
||||||
vendorId: providerType,
|
vendorId: providerType,
|
||||||
label: accountLabel || existing?.label || 'Google Gemini',
|
label: accountLabel || existing?.label || accountLabelDefault,
|
||||||
authMode: 'oauth_browser',
|
authMode: 'oauth_browser',
|
||||||
baseUrl: existing?.baseUrl,
|
baseUrl: existing?.baseUrl,
|
||||||
apiProtocol: existing?.apiProtocol,
|
apiProtocol: existing?.apiProtocol,
|
||||||
model: existing?.model || GOOGLE_OAUTH_DEFAULT_MODEL,
|
model: normalizedExistingModel || defaultModel,
|
||||||
fallbackModels: existing?.fallbackModels,
|
fallbackModels: existing?.fallbackModels,
|
||||||
fallbackAccountIds: existing?.fallbackAccountIds,
|
fallbackAccountIds: existing?.fallbackAccountIds,
|
||||||
enabled: existing?.enabled ?? true,
|
enabled: existing?.enabled ?? true,
|
||||||
isDefault: existing?.isDefault ?? false,
|
isDefault: existing?.isDefault ?? false,
|
||||||
metadata: {
|
metadata: {
|
||||||
...existing?.metadata,
|
...existing?.metadata,
|
||||||
email: token.email,
|
email: oauthTokenEmail,
|
||||||
resourceUrl: GOOGLE_RUNTIME_PROVIDER_ID,
|
resourceUrl: runtimeProviderId,
|
||||||
},
|
},
|
||||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
@@ -128,16 +207,16 @@ class BrowserOAuthManager extends EventEmitter {
|
|||||||
accessToken: token.access,
|
accessToken: token.access,
|
||||||
refreshToken: token.refresh,
|
refreshToken: token.refresh,
|
||||||
expiresAt: token.expires,
|
expiresAt: token.expires,
|
||||||
email: token.email,
|
email: oauthTokenEmail,
|
||||||
subject: token.projectId,
|
subject: oauthTokenSubject,
|
||||||
});
|
});
|
||||||
|
|
||||||
await saveOAuthTokenToOpenClaw(GOOGLE_RUNTIME_PROVIDER_ID, {
|
await saveOAuthTokenToOpenClaw(runtimeProviderId, {
|
||||||
access: token.access,
|
access: token.access,
|
||||||
refresh: token.refresh,
|
refresh: token.refresh,
|
||||||
expires: token.expires,
|
expires: token.expires,
|
||||||
email: token.email,
|
email: oauthTokenEmail,
|
||||||
projectId: token.projectId,
|
projectId: oauthTokenSubject,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id });
|
this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id });
|
||||||
|
|||||||
304
electron/utils/openai-codex-oauth.ts
Normal file
304
electron/utils/openai-codex-oauth.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
|
||||||
|
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||||
|
const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
|
||||||
|
const TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
||||||
|
// Must match the redirect URI expected by OpenAI Codex OAuth client.
|
||||||
|
const REDIRECT_URI = 'http://localhost:1455/auth/callback';
|
||||||
|
const SCOPE = 'openid profile email offline_access';
|
||||||
|
const JWT_CLAIM_PATH = 'https://api.openai.com/auth';
|
||||||
|
const ORIGINATOR = 'codex_cli_rs';
|
||||||
|
|
||||||
|
const SUCCESS_HTML = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Authentication successful</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Authentication successful. Return to ClawX to continue.</p>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
export interface OpenAICodexOAuthCredentials {
|
||||||
|
access: string;
|
||||||
|
refresh: string;
|
||||||
|
expires: number;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenAICodexAuthorizationFlow {
|
||||||
|
verifier: string;
|
||||||
|
state: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenAICodexLocalServer {
|
||||||
|
close: () => void;
|
||||||
|
waitForCode: () => Promise<{ code: string } | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64Url(buffer: Buffer): string {
|
||||||
|
return buffer
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPkce(): { verifier: string; challenge: string } {
|
||||||
|
const verifier = toBase64Url(randomBytes(32));
|
||||||
|
const challenge = toBase64Url(createHash('sha256').update(verifier).digest());
|
||||||
|
return { verifier, challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createState(): string {
|
||||||
|
return toBase64Url(randomBytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
|
||||||
|
const value = input.trim();
|
||||||
|
if (!value) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return {
|
||||||
|
code: url.searchParams.get('code') ?? undefined,
|
||||||
|
state: url.searchParams.get('state') ?? undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// not a URL
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes('#')) {
|
||||||
|
const [code, state] = value.split('#', 2);
|
||||||
|
return { code, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes('code=')) {
|
||||||
|
const params = new URLSearchParams(value);
|
||||||
|
return {
|
||||||
|
code: params.get('code') ?? undefined,
|
||||||
|
state: params.get('state') ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { code: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parts[1];
|
||||||
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||||
|
const decoded = Buffer.from(padded, 'base64').toString('utf8');
|
||||||
|
return JSON.parse(decoded) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAccountIdFromAccessToken(accessToken: string): string | null {
|
||||||
|
const payload = decodeJwtPayload(accessToken);
|
||||||
|
const authClaims = payload?.[JWT_CLAIM_PATH];
|
||||||
|
if (!authClaims || typeof authClaims !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountId = (authClaims as Record<string, unknown>).chatgpt_account_id;
|
||||||
|
if (typeof accountId !== 'string' || !accountId.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAuthorizationFlow(): Promise<OpenAICodexAuthorizationFlow> {
|
||||||
|
const { verifier, challenge } = createPkce();
|
||||||
|
const state = createState();
|
||||||
|
const url = new URL(AUTHORIZE_URL);
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('client_id', CLIENT_ID);
|
||||||
|
url.searchParams.set('redirect_uri', REDIRECT_URI);
|
||||||
|
url.searchParams.set('scope', SCOPE);
|
||||||
|
url.searchParams.set('code_challenge', challenge);
|
||||||
|
url.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
url.searchParams.set('id_token_add_organizations', 'true');
|
||||||
|
url.searchParams.set('codex_cli_simplified_flow', 'true');
|
||||||
|
url.searchParams.set('originator', ORIGINATOR);
|
||||||
|
|
||||||
|
return { verifier, state, url: url.toString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLocalOAuthServer(state: string): Promise<OpenAICodexLocalServer | null> {
|
||||||
|
let lastCode: string | null = null;
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url || '', 'http://localhost');
|
||||||
|
if (url.pathname !== '/auth/callback') {
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.end('Not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.searchParams.get('state') !== state) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('State mismatch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
if (!code) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Missing authorization code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastCode = code;
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.end(SUCCESS_HTML);
|
||||||
|
} catch {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('Internal error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
server
|
||||||
|
.listen(1455, 'localhost', () => {
|
||||||
|
resolve({
|
||||||
|
close: () => server.close(),
|
||||||
|
waitForCode: async () => {
|
||||||
|
const sleep = () => new Promise((r) => setTimeout(r, 100));
|
||||||
|
for (let i = 0; i < 600; i += 1) {
|
||||||
|
if (lastCode) {
|
||||||
|
return { code: lastCode };
|
||||||
|
}
|
||||||
|
await sleep();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.on('error', () => {
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeAuthorizationCode(
|
||||||
|
code: string,
|
||||||
|
verifier: string,
|
||||||
|
): Promise<{ access: string; refresh: string; expires: number }> {
|
||||||
|
const response = await fetch(TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(`OpenAI token exchange failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json() as {
|
||||||
|
access_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
};
|
||||||
|
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== 'number') {
|
||||||
|
throw new Error('OpenAI token response missing fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
access: json.access_token,
|
||||||
|
refresh: json.refresh_token,
|
||||||
|
expires: Date.now() + json.expires_in * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginOpenAICodexOAuth(options: {
|
||||||
|
openUrl: (url: string) => Promise<void>;
|
||||||
|
onProgress?: (message: string) => void;
|
||||||
|
onManualCodeRequired?: (payload: { authorizationUrl: string; reason: 'port_in_use' | 'callback_timeout' }) => void;
|
||||||
|
onManualCodeInput?: () => Promise<string>;
|
||||||
|
}): Promise<OpenAICodexOAuthCredentials> {
|
||||||
|
const { verifier, state, url } = await createAuthorizationFlow();
|
||||||
|
options.onProgress?.('Opening OpenAI sign-in page…');
|
||||||
|
|
||||||
|
const server = await startLocalOAuthServer(state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await options.openUrl(url);
|
||||||
|
options.onProgress?.(
|
||||||
|
server ? 'Waiting for OpenAI OAuth callback…' : 'Callback port unavailable, waiting for manual authorization code…',
|
||||||
|
);
|
||||||
|
|
||||||
|
let code: string | undefined;
|
||||||
|
if (server) {
|
||||||
|
const result = await server.waitForCode();
|
||||||
|
code = result?.code ?? undefined;
|
||||||
|
if (!code && options.onManualCodeInput) {
|
||||||
|
options.onManualCodeRequired?.({ authorizationUrl: url, reason: 'callback_timeout' });
|
||||||
|
code = await options.onManualCodeInput();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!options.onManualCodeInput) {
|
||||||
|
throw new Error('Cannot start OpenAI OAuth callback server on localhost:1455');
|
||||||
|
}
|
||||||
|
options.onManualCodeRequired?.({ authorizationUrl: url, reason: 'port_in_use' });
|
||||||
|
code = await options.onManualCodeInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('Missing OpenAI authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseAuthorizationInput(code);
|
||||||
|
if (parsed.state && parsed.state !== state) {
|
||||||
|
throw new Error('OpenAI OAuth state mismatch');
|
||||||
|
}
|
||||||
|
code = parsed.code;
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('Missing OpenAI authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await exchangeAuthorizationCode(code, verifier);
|
||||||
|
const accountId = getAccountIdFromAccessToken(token.access);
|
||||||
|
if (!accountId) {
|
||||||
|
throw new Error('Failed to extract OpenAI accountId from token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
access: token.access,
|
||||||
|
refresh: token.refresh,
|
||||||
|
expires: token.expires,
|
||||||
|
accountId,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
server?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -752,10 +752,16 @@ function AddProviderDialog({
|
|||||||
// OAuth Flow State
|
// OAuth Flow State
|
||||||
const [oauthFlowing, setOauthFlowing] = useState(false);
|
const [oauthFlowing, setOauthFlowing] = useState(false);
|
||||||
const [oauthData, setOauthData] = useState<{
|
const [oauthData, setOauthData] = useState<{
|
||||||
|
mode: 'device';
|
||||||
verificationUri: string;
|
verificationUri: string;
|
||||||
userCode: string;
|
userCode: string;
|
||||||
expiresIn: number;
|
expiresIn: number;
|
||||||
|
} | {
|
||||||
|
mode: 'manual';
|
||||||
|
authorizationUrl: string;
|
||||||
|
message?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [manualCodeInput, setManualCodeInput] = useState('');
|
||||||
const [oauthError, setOauthError] = useState<string | null>(null);
|
const [oauthError, setOauthError] = useState<string | null>(null);
|
||||||
// For providers that support both OAuth and API key, let the user choose.
|
// For providers that support both OAuth and API key, let the user choose.
|
||||||
// Default to the vendor's declared auth mode instead of hard-coding OAuth.
|
// Default to the vendor's declared auth mode instead of hard-coding OAuth.
|
||||||
@@ -792,13 +798,28 @@ function AddProviderDialog({
|
|||||||
// Manage OAuth events
|
// Manage OAuth events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCode = (data: unknown) => {
|
const handleCode = (data: unknown) => {
|
||||||
setOauthData(data as { verificationUri: string; userCode: string; expiresIn: number });
|
const payload = data as Record<string, unknown>;
|
||||||
|
if (payload?.mode === 'manual') {
|
||||||
|
setOauthData({
|
||||||
|
mode: 'manual',
|
||||||
|
authorizationUrl: String(payload.authorizationUrl || ''),
|
||||||
|
message: typeof payload.message === 'string' ? payload.message : undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOauthData({
|
||||||
|
mode: 'device',
|
||||||
|
verificationUri: String(payload.verificationUri || ''),
|
||||||
|
userCode: String(payload.userCode || ''),
|
||||||
|
expiresIn: Number(payload.expiresIn || 300),
|
||||||
|
});
|
||||||
|
}
|
||||||
setOauthError(null);
|
setOauthError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSuccess = async (data: unknown) => {
|
const handleSuccess = async (data: unknown) => {
|
||||||
setOauthFlowing(false);
|
setOauthFlowing(false);
|
||||||
setOauthData(null);
|
setOauthData(null);
|
||||||
|
setManualCodeInput('');
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
|
|
||||||
const { onClose: close, t: translate } = latestRef.current;
|
const { onClose: close, t: translate } = latestRef.current;
|
||||||
@@ -813,8 +834,9 @@ function AddProviderDialog({
|
|||||||
const store = useProviderStore.getState();
|
const store = useProviderStore.getState();
|
||||||
await store.refreshProviderSnapshot();
|
await store.refreshProviderSnapshot();
|
||||||
|
|
||||||
// Auto-set as default if no default is currently configured
|
// OAuth sign-in should immediately become active default to avoid
|
||||||
if (!store.defaultAccountId && accountId) {
|
// leaving runtime on an API-key-only provider/model.
|
||||||
|
if (accountId) {
|
||||||
await store.setDefaultAccount(accountId);
|
await store.setDefaultAccount(accountId);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -857,6 +879,7 @@ function AddProviderDialog({
|
|||||||
|
|
||||||
setOauthFlowing(true);
|
setOauthFlowing(true);
|
||||||
setOauthData(null);
|
setOauthData(null);
|
||||||
|
setManualCodeInput('');
|
||||||
setOauthError(null);
|
setOauthError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -879,6 +902,7 @@ function AddProviderDialog({
|
|||||||
const handleCancelOAuth = async () => {
|
const handleCancelOAuth = async () => {
|
||||||
setOauthFlowing(false);
|
setOauthFlowing(false);
|
||||||
setOauthData(null);
|
setOauthData(null);
|
||||||
|
setManualCodeInput('');
|
||||||
setOauthError(null);
|
setOauthError(null);
|
||||||
pendingOAuthRef.current = null;
|
pendingOAuthRef.current = null;
|
||||||
await hostApiFetch('/api/providers/oauth/cancel', {
|
await hostApiFetch('/api/providers/oauth/cancel', {
|
||||||
@@ -886,6 +910,20 @@ function AddProviderDialog({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmitManualOAuthCode = async () => {
|
||||||
|
const value = manualCodeInput.trim();
|
||||||
|
if (!value) return;
|
||||||
|
try {
|
||||||
|
await hostApiFetch('/api/providers/oauth/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code: value }),
|
||||||
|
});
|
||||||
|
setOauthError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setOauthError(String(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const availableTypes = PROVIDER_TYPE_INFO.filter((type) => {
|
const availableTypes = PROVIDER_TYPE_INFO.filter((type) => {
|
||||||
const vendor = vendorMap.get(type.id);
|
const vendor = vendorMap.get(type.id);
|
||||||
if (!vendor) {
|
if (!vendor) {
|
||||||
@@ -1198,6 +1236,43 @@ function AddProviderDialog({
|
|||||||
<Loader2 className="h-10 w-10 animate-spin text-blue-500 mx-auto" />
|
<Loader2 className="h-10 w-10 animate-spin text-blue-500 mx-auto" />
|
||||||
<p className="text-[13px] font-medium text-muted-foreground animate-pulse">{t('aiProviders.oauth.requestingCode')}</p>
|
<p className="text-[13px] font-medium text-muted-foreground animate-pulse">{t('aiProviders.oauth.requestingCode')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : oauthData.mode === 'manual' ? (
|
||||||
|
<div className="space-y-4 w-full">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-[16px] text-foreground">Complete OpenAI Login</h3>
|
||||||
|
<p className="text-[13px] text-muted-foreground text-left bg-black/5 dark:bg-white/5 p-4 rounded-xl">
|
||||||
|
{oauthData.message || 'Open the authorization page, complete login, then paste the callback URL or code below.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full rounded-full h-[42px] font-semibold"
|
||||||
|
onClick={() => invokeIpc('shell:openExternal', oauthData.authorizationUrl)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
Open Authorization Page
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Paste callback URL or code"
|
||||||
|
value={manualCodeInput}
|
||||||
|
onChange={(e) => setManualCodeInput(e.target.value)}
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full rounded-full h-[42px] font-semibold bg-[#0a84ff] hover:bg-[#007aff] text-white"
|
||||||
|
onClick={handleSubmitManualOAuthCode}
|
||||||
|
disabled={!manualCodeInput.trim()}
|
||||||
|
>
|
||||||
|
Submit Code
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="ghost" className="w-full rounded-full h-[42px] font-semibold text-muted-foreground" onClick={handleCancelOAuth}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5 w-full">
|
<div className="space-y-5 w-full">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1272,4 +1347,4 @@ function AddProviderDialog({
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,17 @@ import { providerIcons } from '@/assets/providers';
|
|||||||
/** All supported provider types with UI metadata */
|
/** All supported provider types with UI metadata */
|
||||||
export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
|
export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
|
||||||
{ id: 'anthropic', name: 'Anthropic', icon: '🤖', placeholder: 'sk-ant-api03-...', model: 'Claude', requiresApiKey: true },
|
{ 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: 'openai',
|
||||||
|
name: 'OpenAI',
|
||||||
|
icon: '💚',
|
||||||
|
placeholder: 'sk-proj-...',
|
||||||
|
model: 'GPT',
|
||||||
|
requiresApiKey: true,
|
||||||
|
isOAuth: true,
|
||||||
|
supportsApiKey: true,
|
||||||
|
apiKeyUrl: 'https://platform.openai.com/api-keys',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'google',
|
id: 'google',
|
||||||
name: 'Google',
|
name: 'Google',
|
||||||
|
|||||||
@@ -720,23 +720,44 @@ function ProviderContent({
|
|||||||
// OAuth Flow State
|
// OAuth Flow State
|
||||||
const [oauthFlowing, setOauthFlowing] = useState(false);
|
const [oauthFlowing, setOauthFlowing] = useState(false);
|
||||||
const [oauthData, setOauthData] = useState<{
|
const [oauthData, setOauthData] = useState<{
|
||||||
|
mode: 'device';
|
||||||
verificationUri: string;
|
verificationUri: string;
|
||||||
userCode: string;
|
userCode: string;
|
||||||
expiresIn: number;
|
expiresIn: number;
|
||||||
|
} | {
|
||||||
|
mode: 'manual';
|
||||||
|
authorizationUrl: string;
|
||||||
|
message?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [manualCodeInput, setManualCodeInput] = useState('');
|
||||||
const [oauthError, setOauthError] = useState<string | null>(null);
|
const [oauthError, setOauthError] = useState<string | null>(null);
|
||||||
const pendingOAuthRef = useRef<{ accountId: string; label: string } | null>(null);
|
const pendingOAuthRef = useRef<{ accountId: string; label: string } | null>(null);
|
||||||
|
|
||||||
// Manage OAuth events
|
// Manage OAuth events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCode = (data: unknown) => {
|
const handleCode = (data: unknown) => {
|
||||||
setOauthData(data as { verificationUri: string; userCode: string; expiresIn: number });
|
const payload = data as Record<string, unknown>;
|
||||||
|
if (payload?.mode === 'manual') {
|
||||||
|
setOauthData({
|
||||||
|
mode: 'manual',
|
||||||
|
authorizationUrl: String(payload.authorizationUrl || ''),
|
||||||
|
message: typeof payload.message === 'string' ? payload.message : undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setOauthData({
|
||||||
|
mode: 'device',
|
||||||
|
verificationUri: String(payload.verificationUri || ''),
|
||||||
|
userCode: String(payload.userCode || ''),
|
||||||
|
expiresIn: Number(payload.expiresIn || 300),
|
||||||
|
});
|
||||||
|
}
|
||||||
setOauthError(null);
|
setOauthError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSuccess = async (data: unknown) => {
|
const handleSuccess = async (data: unknown) => {
|
||||||
setOauthFlowing(false);
|
setOauthFlowing(false);
|
||||||
setOauthData(null);
|
setOauthData(null);
|
||||||
|
setManualCodeInput('');
|
||||||
setKeyValid(true);
|
setKeyValid(true);
|
||||||
|
|
||||||
const payload = (data as { accountId?: string } | undefined) || undefined;
|
const payload = (data as { accountId?: string } | undefined) || undefined;
|
||||||
@@ -796,6 +817,7 @@ function ProviderContent({
|
|||||||
|
|
||||||
setOauthFlowing(true);
|
setOauthFlowing(true);
|
||||||
setOauthData(null);
|
setOauthData(null);
|
||||||
|
setManualCodeInput('');
|
||||||
setOauthError(null);
|
setOauthError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -821,11 +843,26 @@ function ProviderContent({
|
|||||||
const handleCancelOAuth = async () => {
|
const handleCancelOAuth = async () => {
|
||||||
setOauthFlowing(false);
|
setOauthFlowing(false);
|
||||||
setOauthData(null);
|
setOauthData(null);
|
||||||
|
setManualCodeInput('');
|
||||||
setOauthError(null);
|
setOauthError(null);
|
||||||
pendingOAuthRef.current = null;
|
pendingOAuthRef.current = null;
|
||||||
await hostApiFetch('/api/providers/oauth/cancel', { method: 'POST' });
|
await hostApiFetch('/api/providers/oauth/cancel', { method: 'POST' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmitManualOAuthCode = async () => {
|
||||||
|
const value = manualCodeInput.trim();
|
||||||
|
if (!value) return;
|
||||||
|
try {
|
||||||
|
await hostApiFetch('/api/providers/oauth/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code: value }),
|
||||||
|
});
|
||||||
|
setOauthError(null);
|
||||||
|
} catch (error) {
|
||||||
|
setOauthError(String(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// On mount, try to restore previously configured provider
|
// On mount, try to restore previously configured provider
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -1303,6 +1340,42 @@ function ProviderContent({
|
|||||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
|
||||||
<p className="text-sm text-muted-foreground animate-pulse">Requesting secure login code...</p>
|
<p className="text-sm text-muted-foreground animate-pulse">Requesting secure login code...</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : oauthData.mode === 'manual' ? (
|
||||||
|
<div className="space-y-4 w-full">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium text-lg">Complete OpenAI Login</h3>
|
||||||
|
<p className="text-sm text-muted-foreground text-left mt-2">
|
||||||
|
{oauthData.message || 'Open the authorization page, complete login, then paste the callback URL or code below.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => invokeIpc('shell:openExternal', oauthData.authorizationUrl)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
Open Authorization Page
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Paste callback URL or code"
|
||||||
|
value={manualCodeInput}
|
||||||
|
onChange={(e) => setManualCodeInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
onClick={handleSubmitManualOAuthCode}
|
||||||
|
disabled={!manualCodeInput.trim()}
|
||||||
|
>
|
||||||
|
Submit Code
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="ghost" size="sm" className="w-full mt-2" onClick={handleCancelOAuth}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 w-full">
|
<div className="space-y-4 w-full">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user