feat(provider): add OpenAI Codex browser OAuth flow (#398)

Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
Lingxuan Zuo
2026-03-11 09:40:00 +08:00
committed by GitHub
Unverified
parent 880995af19
commit 31e80f256b
13 changed files with 655 additions and 70 deletions

View File

@@ -110,7 +110,7 @@ AIタスクを自動的に実行するようスケジュール設定できます
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。 事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。
### 🔐 セキュアなプロバイダー統合 ### 🔐 セキュアなプロバイダー統合
複数のAIプロバイダーOpenAI、Anthropicなどに接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。 複数のAIプロバイダーOpenAI、Anthropicなどに接続でき、資格情報はシステムのネイティブキーチェーンに安全に保存されます。OpenAI は API キーとブラウザ OAuthCodex サブスクリプション)の両方に対応しています。
### 🌙 アダプティブテーマ ### 🌙 アダプティブテーマ
ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。 ライトモード、ダークモード、またはシステム同期テーマ。ClawXはあなたの好みに自動的に適応します。
@@ -149,7 +149,7 @@ pnpm dev
ClawXを初めて起動すると、**セットアップウィザード**が以下の手順をガイドします: ClawXを初めて起動すると、**セットアップウィザード**が以下の手順をガイドします:
1. **言語と地域** 使用する言語・地域の設定 1. **言語と地域** 使用する言語・地域の設定
2. **AIプロバイダー** サポートされているプロバイダーのAPIキーを入力 2. **AIプロバイダー** APIキーまたは OAuthブラウザ/デバイスログイン対応プロバイダー)で追加
3. **スキルバンドル** 一般的なユースケース向けの事前設定スキルを選択 3. **スキルバンドル** 一般的なユースケース向けの事前設定スキルを選択
4. **検証** メインインターフェースに入る前に設定をテスト 4. **検証** メインインターフェースに入る前に設定をテスト

View File

@@ -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

View File

@@ -111,7 +111,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。 通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。
### 🔐 安全的供应商集成 ### 🔐 安全的供应商集成
连接多个 AI 供应商OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。 连接多个 AI 供应商OpenAI、Anthropic 等),凭证安全存储在系统原生密钥链中。OpenAI 同时支持 API Key 与浏览器 OAuthCodex 订阅)登录。
### 🌙 自适应主题 ### 🌙 自适应主题
支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。 支持浅色模式、深色模式或跟随系统主题。ClawX 自动适应你的偏好设置。
@@ -150,7 +150,7 @@ pnpm dev
首次启动 ClawX 时,**设置向导** 将引导你完成以下步骤: 首次启动 ClawX 时,**设置向导** 将引导你完成以下步骤:
1. **语言与区域** 配置你的首选语言和地区 1. **语言与区域** 配置你的首选语言和地区
2. **AI 供应商** 输入所支持供应商的 API 密钥 2. **AI 供应商** 通过 API 密钥或 OAuth支持浏览器/设备登录的供应商)添加账号
3. **技能包** 选择适用于常见场景的预配置技能 3. **技能包** 选择适用于常见场景的预配置技能
4. **验证** 在进入主界面前测试你的配置 4. **验证** 在进入主界面前测试你的配置

View File

@@ -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 {

View File

@@ -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 });
}); });

View File

@@ -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);

View File

@@ -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;
} }

View File

@@ -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: {

View File

@@ -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 });

View 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();
}
}

View File

@@ -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">

View File

@@ -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',

View File

@@ -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">