diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx index 9e1b664592..e8d8f16d19 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/AISettingsPage.tsx @@ -1,5 +1,5 @@ import { useQueryClient } from '@tanstack/react-query' -import { type FC, useMemo, useState } from 'react' +import { type FC, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { AlertDialog, @@ -13,14 +13,24 @@ import { } from '@/components/ui/alert-dialog' import { useSessionInfo } from '@/lib/auth/sessionStorage' import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders' +import { + CHATGPT_PRO_OAUTH_COMPLETED_EVENT, + CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT, + CHATGPT_PRO_OAUTH_STARTED_EVENT, +} from '@/lib/constants/analyticsEvents' import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument' import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument' import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation' import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery' -import type { ProviderTemplate } from '@/lib/llm-providers/providerTemplates' +import { + getProviderTemplate, + type ProviderTemplate, +} from '@/lib/llm-providers/providerTemplates' import { testProvider } from '@/lib/llm-providers/testProvider' import type { LlmProviderConfig } from '@/lib/llm-providers/types' import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders' +import { useOAuthStatus } from '@/lib/llm-providers/useOAuthStatus' +import { track } from '@/lib/metrics/track' import { ConfiguredProvidersList } from './ConfiguredProvidersList' import { DeleteRemoteLlmProviderDocument, @@ -101,12 +111,69 @@ export const AISettingsPage: FC = () => { null, ) + // OAuth status for ChatGPT Pro + const { + status: chatgptProStatus, + startPolling: startChatGPTProPolling, + disconnect: disconnectChatGPTPro, + } = useOAuthStatus('chatgpt-pro') + + // Track whether user explicitly started an OAuth flow this session + const oauthFlowStartedRef = useRef(false) + + // Auto-create provider only when user actively completed OAuth, + // not on passive page load when server has old tokens + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change + useEffect(() => { + if (!chatgptProStatus?.authenticated) return + if (!oauthFlowStartedRef.current) return + + const exists = providers.some((p) => p.type === 'chatgpt-pro') + if (exists) return + + const now = Date.now() + try { + const template = getProviderTemplate('chatgpt-pro') + saveProvider({ + id: `chatgpt-pro-${now}`, + type: 'chatgpt-pro', + name: `ChatGPT Pro${chatgptProStatus.email ? ` (${chatgptProStatus.email})` : ''}`, + modelId: template?.defaultModelId ?? 'gpt-5.3-codex', + supportsImages: template?.supportsImages ?? true, + contextWindow: template?.contextWindow ?? 400000, + temperature: 0.2, + createdAt: now, + updatedAt: now, + }) + track(CHATGPT_PRO_OAUTH_COMPLETED_EVENT, { + email: chatgptProStatus.email, + }) + toast.success('ChatGPT Pro Connected', { + description: chatgptProStatus.email + ? `Authenticated as ${chatgptProStatus.email}` + : 'Successfully authenticated with ChatGPT Pro', + }) + } catch (err) { + toast.error('Failed to create ChatGPT Pro provider', { + description: err instanceof Error ? err.message : 'Unknown error', + }) + } finally { + oauthFlowStartedRef.current = false + } + }, [chatgptProStatus?.authenticated]) + const handleAddProvider = () => { setTemplateValues(undefined) setIsNewDialogOpen(true) } const handleUseTemplate = (template: ProviderTemplate) => { + // OAuth providers: trigger OAuth flow instead of opening form dialog + if (template.id === 'chatgpt-pro') { + handleStartChatGPTProOAuth() + return + } + setTemplateValues({ type: template.id, name: template.name, @@ -119,6 +186,27 @@ export const AISettingsPage: FC = () => { setIsNewDialogOpen(true) } + const handleStartChatGPTProOAuth = () => { + if (!agentServerUrl) { + toast.error('Server not available', { + description: 'Cannot start OAuth flow without server connection.', + }) + return + } + oauthFlowStartedRef.current = true + + const extensionSettingsUrl = chrome.runtime.getURL('app.html#/ai-settings') + const startUrl = `${agentServerUrl}/oauth/chatgpt-pro/start?redirect=${encodeURIComponent(extensionSettingsUrl)}` + window.open(startUrl, '_blank') + + // Start polling for OAuth completion + startChatGPTProPolling() + track(CHATGPT_PRO_OAUTH_STARTED_EVENT) + toast.info('Authenticating with ChatGPT Pro', { + description: 'Complete the login in the opened tab.', + }) + } + const handleEditProvider = (provider: LlmProviderConfig) => { setEditingProvider(provider) setIsEditDialogOpen(true) @@ -130,6 +218,11 @@ export const AISettingsPage: FC = () => { const confirmDeleteProvider = async () => { if (providerToDelete) { + // Clear OAuth tokens on server for OAuth-based providers + if (providerToDelete.type === 'chatgpt-pro') { + await disconnectChatGPTPro() + track(CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT) + } await deleteProvider(providerToDelete.id) deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id }) setProviderToDelete(null) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/NewProviderDialog.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/NewProviderDialog.tsx index ab616864e4..0de60657e6 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/NewProviderDialog.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/NewProviderDialog.tsx @@ -61,6 +61,7 @@ const providerTypeEnum = z.enum([ 'lmstudio', 'bedrock', 'browseros', + 'chatgpt-pro', ]) /** @@ -84,6 +85,9 @@ export const providerFormSchema = z secretAccessKey: z.string().optional(), region: z.string().optional(), sessionToken: z.string().optional(), + // ChatGPT Pro (Codex) + reasoningEffort: z.enum(['none', 'low', 'medium', 'high']).optional(), + reasoningSummary: z.enum(['auto', 'concise', 'detailed']).optional(), }) .superRefine((data, ctx) => { // Azure: require either resourceName or baseUrl @@ -127,6 +131,10 @@ export const providerFormSchema = z }) } } + // ChatGPT Pro: no credentials needed (server-managed OAuth) + else if (data.type === 'chatgpt-pro') { + // No validation needed — OAuth tokens are on the server + } // Other providers: require baseUrl else if (!data.baseUrl) { ctx.addIssue({ @@ -209,6 +217,8 @@ export const NewProviderDialog: FC = ({ secretAccessKey: initialValues?.secretAccessKey || '', region: initialValues?.region || '', sessionToken: initialValues?.sessionToken || '', + reasoningEffort: initialValues?.reasoningEffort || 'high', + reasoningSummary: initialValues?.reasoningSummary || 'auto', }, }) @@ -301,6 +311,8 @@ export const NewProviderDialog: FC = ({ secretAccessKey: initialValues.secretAccessKey || '', region: initialValues.region || '', sessionToken: initialValues.sessionToken || '', + reasoningEffort: initialValues.reasoningEffort || 'high', + reasoningSummary: initialValues.reasoningSummary || 'auto', }) setIsCustomModel(false) } @@ -326,6 +338,8 @@ export const NewProviderDialog: FC = ({ secretAccessKey: '', region: '', sessionToken: '', + reasoningEffort: 'high', + reasoningSummary: 'auto', }) setIsCustomModel(false) } @@ -363,6 +377,9 @@ export const NewProviderDialog: FC = ({ const canTest = (): boolean => { if (!watchedModelId) return false + // ChatGPT Pro: always testable (server has the OAuth token) + if (watchedType === 'chatgpt-pro') return true + if (watchedType === 'azure') { return !!(watchedResourceName || watchedBaseUrl) && !!watchedApiKey } @@ -444,6 +461,76 @@ export const NewProviderDialog: FC = ({ } const renderProviderSpecificFields = () => { + // ChatGPT Pro: OAuth credentials + Codex reasoning settings + if (watchedType === 'chatgpt-pro') { + return ( + <> +
+ Credentials are managed via OAuth. No API key needed. +
+
+ ( + + Reasoning Effort + + + How much the model thinks before responding + + + + )} + /> + ( + + Reasoning Summary + + + Detail level of visible thinking steps + + + + )} + /> +
+ + ) + } + if (watchedType === 'azure') { return ( <> diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/models.ts b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/models.ts index 07a5a27e7b..d344c30805 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/models.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/ai-settings/models.ts @@ -23,6 +23,7 @@ export interface ModelsData { bedrock: ModelInfo[] browseros: ModelInfo[] moonshot: ModelInfo[] + 'chatgpt-pro': ModelInfo[] } /** @@ -90,6 +91,16 @@ export const MODELS_DATA: ModelsData = { ], bedrock: [], browseros: [{ modelId: 'browseros-auto', contextLength: 200000 }], + 'chatgpt-pro': [ + { modelId: 'gpt-5.4', contextLength: 400000 }, + { modelId: 'gpt-5.3-codex', contextLength: 400000 }, + { modelId: 'gpt-5.2-codex', contextLength: 400000 }, + { modelId: 'gpt-5.2', contextLength: 200000 }, + { modelId: 'gpt-5.1-codex', contextLength: 400000 }, + { modelId: 'gpt-5.1-codex-max', contextLength: 400000 }, + { modelId: 'gpt-5.1-codex-mini', contextLength: 400000 }, + { modelId: 'gpt-5.1', contextLength: 200000 }, + ], } /** diff --git a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts index a38d43a1ab..8eba62bb86 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/index/useChatSession.ts @@ -308,6 +308,9 @@ export const useChatSession = (options?: ChatSessionOptions) => { secretAccessKey: provider?.secretAccessKey, region: provider?.region, sessionToken: provider?.sessionToken, + // ChatGPT Pro (Codex) + reasoningEffort: provider?.reasoningEffort, + reasoningSummary: provider?.reasoningSummary, browserContext, userSystemPrompt: options?.origin === 'newtab' diff --git a/packages/browseros-agent/apps/agent/lib/constants/analyticsEvents.ts b/packages/browseros-agent/apps/agent/lib/constants/analyticsEvents.ts index 5b9c276163..25b72ebbb8 100644 --- a/packages/browseros-agent/apps/agent/lib/constants/analyticsEvents.ts +++ b/packages/browseros-agent/apps/agent/lib/constants/analyticsEvents.ts @@ -29,6 +29,18 @@ export const CONVERSATION_RESET_EVENT = 'ui.conversation.reset' /** @public */ export const AI_PROVIDER_ADDED_EVENT = 'settings.ai_provider.added' +/** @public */ +export const CHATGPT_PRO_OAUTH_STARTED_EVENT = + 'settings.chatgpt_pro.oauth_started' + +/** @public */ +export const CHATGPT_PRO_OAUTH_COMPLETED_EVENT = + 'settings.chatgpt_pro.oauth_completed' + +/** @public */ +export const CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT = + 'settings.chatgpt_pro.oauth_disconnected' + /** @public */ export const HUB_PROVIDER_ADDED_EVENT = 'settings.hub_provider.added' diff --git a/packages/browseros-agent/apps/agent/lib/llm-providers/providerTemplates.ts b/packages/browseros-agent/apps/agent/lib/llm-providers/providerTemplates.ts index 7fce48c9a5..4f21e2ed86 100644 --- a/packages/browseros-agent/apps/agent/lib/llm-providers/providerTemplates.ts +++ b/packages/browseros-agent/apps/agent/lib/llm-providers/providerTemplates.ts @@ -20,6 +20,15 @@ export interface ProviderTemplate { * @public */ export const providerTemplates: ProviderTemplate[] = [ + { + id: 'chatgpt-pro', + name: 'ChatGPT Pro', + defaultBaseUrl: 'https://chatgpt.com/backend-api', + defaultModelId: 'gpt-5.3-codex', + supportsImages: true, + contextWindow: 400000, + setupGuideUrl: 'https://docs.browseros.com/features/chatgpt-pro-oauth', + }, { id: 'moonshot', name: 'Moonshot AI', @@ -129,6 +138,7 @@ export const providerTemplates: ProviderTemplate[] = [ * @public */ export const providerTypeOptions: { value: ProviderType; label: string }[] = [ + { value: 'chatgpt-pro', label: 'ChatGPT Pro' }, { value: 'moonshot', label: 'Moonshot AI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, @@ -157,6 +167,7 @@ export const getProviderTemplate = ( * Auto-fills when user selects a provider type */ export const DEFAULT_BASE_URLS: Record = { + 'chatgpt-pro': 'https://chatgpt.com/backend-api', moonshot: 'https://api.moonshot.ai/v1', anthropic: 'https://api.anthropic.com/v1', openai: 'https://api.openai.com/v1', diff --git a/packages/browseros-agent/apps/agent/lib/llm-providers/types.ts b/packages/browseros-agent/apps/agent/lib/llm-providers/types.ts index 3ca5afa4ee..33ed42e85d 100644 --- a/packages/browseros-agent/apps/agent/lib/llm-providers/types.ts +++ b/packages/browseros-agent/apps/agent/lib/llm-providers/types.ts @@ -14,6 +14,7 @@ export type ProviderType = | 'bedrock' | 'browseros' | 'moonshot' + | 'chatgpt-pro' /** * LLM Provider configuration @@ -56,6 +57,10 @@ export interface LlmProviderConfig { region?: string /** AWS session token (for temporary STS credentials) */ sessionToken?: string + + // ChatGPT Pro (Codex) fields + reasoningEffort?: 'none' | 'low' | 'medium' | 'high' + reasoningSummary?: 'auto' | 'concise' | 'detailed' } /** diff --git a/packages/browseros-agent/apps/agent/lib/llm-providers/useOAuthStatus.ts b/packages/browseros-agent/apps/agent/lib/llm-providers/useOAuthStatus.ts new file mode 100644 index 0000000000..24ffe2af7b --- /dev/null +++ b/packages/browseros-agent/apps/agent/lib/llm-providers/useOAuthStatus.ts @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState } from 'react' +import { getAgentServerUrl } from '@/lib/browseros/helpers' + +interface OAuthStatus { + authenticated: boolean + email?: string + provider: string +} + +interface UseOAuthStatusReturn { + status: OAuthStatus | null + isPolling: boolean + startPolling: () => void + stopPolling: () => void + refresh: () => Promise + disconnect: () => Promise +} + +export function useOAuthStatus(provider: string): UseOAuthStatusReturn { + const [status, setStatus] = useState(null) + const [isPolling, setIsPolling] = useState(false) + const pollIntervalRef = useRef | null>(null) + const pollTimeoutRef = useRef | null>(null) + + async function fetchStatus(): Promise { + try { + const serverUrl = await getAgentServerUrl() + const res = await fetch(`${serverUrl}/oauth/${provider}/status`) + if (!res.ok) return null + const data = (await res.json()) as OAuthStatus + setStatus(data) + return data + } catch { + return null + } + } + + function stopPolling() { + if (pollIntervalRef.current) clearInterval(pollIntervalRef.current) + if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current) + pollIntervalRef.current = null + pollTimeoutRef.current = null + setIsPolling(false) + } + + function startPolling() { + stopPolling() + setIsPolling(true) + + pollIntervalRef.current = setInterval(async () => { + const result = await fetchStatus() + if (result?.authenticated) { + stopPolling() + } + }, 2_000) + + pollTimeoutRef.current = setTimeout(stopPolling, 300_000) + } + + async function disconnect() { + try { + const serverUrl = await getAgentServerUrl() + await fetch(`${serverUrl}/oauth/${provider}`, { method: 'DELETE' }) + setStatus({ authenticated: false, provider }) + } catch { + // Best-effort disconnect + } + } + + // Initial status check on mount + // biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount + useEffect(() => { + fetchStatus() + }, []) + + // Cleanup on unmount + useEffect(() => { + return () => stopPolling() + }, []) + + return { + status, + isPolling, + startPolling, + stopPolling, + refresh: fetchStatus, + disconnect, + } +} diff --git a/packages/browseros-agent/apps/server/src/agent/ai-sdk-agent.ts b/packages/browseros-agent/apps/server/src/agent/ai-sdk-agent.ts index 53e40c8d88..0b21249cdf 100644 --- a/packages/browseros-agent/apps/server/src/agent/ai-sdk-agent.ts +++ b/packages/browseros-agent/apps/server/src/agent/ai-sdk-agent.ts @@ -4,6 +4,7 @@ import type { LanguageModelV3Middleware, } from '@ai-sdk/provider' import { AGENT_LIMITS } from '@browseros/shared/constants/limits' +import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm' import type { BrowserContext } from '@browseros/shared/schemas/browser-context' import { type LanguageModel, @@ -189,13 +190,29 @@ export class AiSdkAgent { ), }) - // Create the ToolLoopAgent + // Codex requires store=false — tell the SDK to inline content + // instead of using item_reference (which fails with store=false) + const isChatGPTPro = + config.resolvedConfig.provider === LLM_PROVIDERS.CHATGPT_PRO + const agent = new ToolLoopAgent({ model, instructions, tools, stopWhen: [stepCountIs(AGENT_LIMITS.MAX_TURNS)], prepareStep, + ...(isChatGPTPro && { + providerOptions: { + openai: { + store: false, + reasoningEffort: + config.resolvedConfig.reasoningEffort || 'high', + reasoningSummary: + config.resolvedConfig.reasoningSummary || 'auto', + include: ['reasoning.encrypted_content'], + }, + }, + }), }) logger.info('Agent session created (v2)', { diff --git a/packages/browseros-agent/apps/server/src/agent/provider-factory.ts b/packages/browseros-agent/apps/server/src/agent/provider-factory.ts index 7b78a903b7..3a374fe620 100644 --- a/packages/browseros-agent/apps/server/src/agent/provider-factory.ts +++ b/packages/browseros-agent/apps/server/src/agent/provider-factory.ts @@ -7,6 +7,7 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible' import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm' import { createOpenRouter } from '@openrouter/ai-sdk-provider' import type { LanguageModel } from 'ai' +import { createCodexFetch } from '../lib/clients/oauth/codex-fetch' import { logger } from '../lib/logger' import { createOpenRouterCompatibleFetch } from '../lib/openrouter-fetch' import type { ResolvedAgentConfig } from './types' @@ -148,6 +149,17 @@ function createMoonshotFactory( }) } +function createChatGPTProFactory( + config: ResolvedAgentConfig, +): (modelId: string) => unknown { + if (!config.apiKey) + throw new Error('ChatGPT Pro requires OAuth authentication') + return createOpenAI({ + apiKey: config.apiKey, + fetch: createCodexFetch(config.accountId) as typeof globalThis.fetch, + }).responses +} + const PROVIDER_FACTORIES: Record = { [LLM_PROVIDERS.ANTHROPIC]: createAnthropicFactory, [LLM_PROVIDERS.OPENAI]: createOpenAIFactory, @@ -160,6 +172,7 @@ const PROVIDER_FACTORIES: Record = { [LLM_PROVIDERS.BROWSEROS]: createBrowserOSFactory, [LLM_PROVIDERS.OPENAI_COMPATIBLE]: createOpenAICompatibleFactory, [LLM_PROVIDERS.MOONSHOT]: createMoonshotFactory, + [LLM_PROVIDERS.CHATGPT_PRO]: createChatGPTProFactory, } export function createLanguageModel( diff --git a/packages/browseros-agent/apps/server/src/agent/types.ts b/packages/browseros-agent/apps/server/src/agent/types.ts index a29adcfe30..ab17e7ab94 100644 --- a/packages/browseros-agent/apps/server/src/agent/types.ts +++ b/packages/browseros-agent/apps/server/src/agent/types.ts @@ -30,6 +30,9 @@ export interface ResolvedAgentConfig { accessKeyId?: string secretAccessKey?: string sessionToken?: string + accountId?: string + reasoningEffort?: string + reasoningSummary?: string contextWindowSize?: number userSystemPrompt?: string workingDir: string diff --git a/packages/browseros-agent/apps/server/src/api/routes/oauth.ts b/packages/browseros-agent/apps/server/src/api/routes/oauth.ts new file mode 100644 index 0000000000..592e290741 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/api/routes/oauth.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * OAuth routes for subscription-based LLM provider authentication. + */ + +import { Hono } from 'hono' +import { getOAuthProvider } from '../../lib/clients/oauth/providers' +import type { OAuthTokenManager } from '../../lib/clients/oauth/token-manager' +import { logger } from '../../lib/logger' + +interface OAuthRouteDeps { + tokenManager: OAuthTokenManager +} + +export function createOAuthRoutes(deps: OAuthRouteDeps) { + const { tokenManager } = deps + + return new Hono() + .get('/:provider/start', async (c) => { + const providerId = c.req.param('provider') + const redirectBackUrl = c.req.query('redirect') + + const provider = getOAuthProvider(providerId) + if (!provider) { + return c.text(`Unknown OAuth provider: ${providerId}`, 400) + } + + try { + const authUrl = await tokenManager.generateAuthorizationUrl( + providerId, + redirectBackUrl, + ) + return c.redirect(authUrl) + } catch (error) { + logger.error('Failed to start OAuth flow', { + provider: providerId, + error: error instanceof Error ? error.message : String(error), + }) + return c.text('Failed to start authentication. Please try again.', 500) + } + }) + + .get('/:provider/status', (c) => { + const providerId = c.req.param('provider') + const status = tokenManager.getStatus(providerId) + return c.json(status) + }) + + .delete('/:provider', (c) => { + const providerId = c.req.param('provider') + tokenManager.deleteTokens(providerId) + logger.info('OAuth tokens deleted', { provider: providerId }) + return c.json({ success: true }) + }) +} diff --git a/packages/browseros-agent/apps/server/src/api/routes/provider.ts b/packages/browseros-agent/apps/server/src/api/routes/provider.ts index 2b952ee5e0..323f69a468 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/provider.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/provider.ts @@ -10,7 +10,11 @@ import { testProviderConnection } from '../../lib/clients/llm/test-provider' import { logger } from '../../lib/logger' import { AgentLLMConfigSchema } from '../types' -export function createProviderRoutes() { +interface ProviderRouteDeps { + browserosId?: string +} + +export function createProviderRoutes(deps: ProviderRouteDeps = {}) { return new Hono().post( '/', zValidator('json', AgentLLMConfigSchema), @@ -22,7 +26,7 @@ export function createProviderRoutes() { model: config.model, }) - const result = await testProviderConnection(config) + const result = await testProviderConnection(config, deps.browserosId) logger.info('Provider test result', { provider: config.provider, diff --git a/packages/browseros-agent/apps/server/src/api/routes/refine-prompt.ts b/packages/browseros-agent/apps/server/src/api/routes/refine-prompt.ts index 31ca1bc57b..f1f4fdc33f 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/refine-prompt.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/refine-prompt.ts @@ -10,7 +10,11 @@ const RefinePromptRequestSchema = AgentLLMConfigSchema.extend({ name: z.string().min(1, 'Task name cannot be empty'), }) -export function createRefinePromptRoutes() { +interface RefinePromptRouteDeps { + browserosId?: string +} + +export function createRefinePromptRoutes(deps: RefinePromptRouteDeps = {}) { return new Hono().post( '/', zValidator('json', RefinePromptRequestSchema), @@ -23,7 +27,11 @@ export function createRefinePromptRoutes() { taskName: name, }) - const result = await refinePrompt(llmConfig, { prompt, name }) + const result = await refinePrompt( + llmConfig, + { prompt, name }, + deps.browserosId, + ) logger.info('Refine prompt result', { provider: llmConfig.provider, diff --git a/packages/browseros-agent/apps/server/src/api/server.ts b/packages/browseros-agent/apps/server/src/api/server.ts index 2835451977..047c06e30c 100644 --- a/packages/browseros-agent/apps/server/src/api/server.ts +++ b/packages/browseros-agent/apps/server/src/api/server.ts @@ -15,6 +15,8 @@ import { cors } from 'hono/cors' import type { ContentfulStatusCode } from 'hono/utils/http-status' import { HttpAgentError } from '../agent/errors' import { KlavisClient } from '../lib/clients/klavis/klavis-client' +import { initializeOAuth } from '../lib/clients/oauth' +import { getDb } from '../lib/db' import { logger } from '../lib/logger' import { createChatRoutes } from './routes/chat' import { createGraphRoutes } from './routes/graph' @@ -22,6 +24,7 @@ import { createHealthRoute } from './routes/health' import { createKlavisRoutes } from './routes/klavis' import { createMcpRoutes } from './routes/mcp' import { createMemoryRoutes } from './routes/memory' +import { createOAuthRoutes } from './routes/oauth' import { createProviderRoutes } from './routes/provider' import { createRefinePromptRoutes } from './routes/refine-prompt' import { createSdkRoutes } from './routes/sdk' @@ -75,6 +78,11 @@ export async function createHttpServer(config: HttpServerConfig) { const { onShutdown } = config + // Initialize OAuth token manager + callback server (port released on process exit) + const tokenManager = browserosId + ? initializeOAuth(getDb(), browserosId) + : null + // Connect Klavis proxy (non-blocking: browser tools still work if this fails) let klavisProxy: KlavisProxyHandle | null = null if (browserosId) { @@ -113,8 +121,16 @@ export async function createHttpServer(config: HttpServerConfig) { .route('/soul', createSoulRoutes()) .route('/memory', createMemoryRoutes()) .route('/skills', createSkillsRoutes()) - .route('/test-provider', createProviderRoutes()) - .route('/refine-prompt', createRefinePromptRoutes()) + .route('/test-provider', createProviderRoutes({ browserosId })) + .route('/refine-prompt', createRefinePromptRoutes({ browserosId })) + .route( + '/oauth', + tokenManager + ? createOAuthRoutes({ tokenManager }) + : new Hono().all('/*', (c) => + c.json({ error: 'OAuth not available' }, 503), + ), + ) .route('/klavis', createKlavisRoutes({ browserosId: browserosId || '' })) .route( '/mcp', diff --git a/packages/browseros-agent/apps/server/src/api/services/chat-service.ts b/packages/browseros-agent/apps/server/src/api/services/chat-service.ts index 9e31f74623..d6160608dd 100644 --- a/packages/browseros-agent/apps/server/src/api/services/chat-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/chat-service.ts @@ -54,6 +54,9 @@ export class ChatService { accessKeyId: llmConfig.accessKeyId, secretAccessKey: llmConfig.secretAccessKey, sessionToken: llmConfig.sessionToken, + accountId: llmConfig.accountId, + reasoningEffort: request.reasoningEffort, + reasoningSummary: request.reasoningSummary, contextWindowSize: request.contextWindowSize, userSystemPrompt: request.userSystemPrompt, workingDir, diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts index ba829ca9c6..b021cee8cf 100644 --- a/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts +++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/config.ts @@ -10,19 +10,57 @@ import { LLM_PROVIDERS, type LLMConfig } from '@browseros/shared/schemas/llm' import { INLINED_ENV } from '../../../env' import { logger } from '../../logger' import { fetchBrowserOSConfig, getLLMConfigFromProvider } from '../gateway' +import { getOAuthTokenManager } from '../oauth' import type { ResolvedLLMConfig } from './types' export async function resolveLLMConfig( config: LLMConfig, browserosId?: string, ): Promise { - if (config.provider !== LLM_PROVIDERS.BROWSEROS) { - if (!config.model) { - throw new Error(`model is required for ${config.provider} provider`) - } - return config as ResolvedLLMConfig + // ChatGPT Pro: resolve OAuth token from server-side storage + if (config.provider === LLM_PROVIDERS.CHATGPT_PRO) { + return resolveChatGPTProConfig(config, browserosId) } + // BrowserOS gateway: fetch config from remote service + if (config.provider === LLM_PROVIDERS.BROWSEROS) { + return resolveBrowserOSConfig(config, browserosId) + } + + // All other providers: passthrough with model validation + if (!config.model) { + throw new Error(`model is required for ${config.provider} provider`) + } + return config as ResolvedLLMConfig +} + +async function resolveChatGPTProConfig( + config: LLMConfig, + browserosId?: string, +): Promise { + const tokenManager = getOAuthTokenManager() + if (!tokenManager || !browserosId) { + throw new Error('Not authenticated with ChatGPT Pro. Please login first.') + } + + const tokens = await tokenManager.refreshIfExpired('chatgpt-pro') + if (!tokens) { + throw new Error('Not authenticated with ChatGPT Pro. Please login first.') + } + + return { + ...config, + model: config.model || 'gpt-5.3-codex', + apiKey: tokens.accessToken, + upstreamProvider: 'openai', + accountId: tokens.accountId, + } +} + +async function resolveBrowserOSConfig( + config: LLMConfig, + browserosId?: string, +): Promise { const configUrl = INLINED_ENV.BROWSEROS_CONFIG_URL if (!configUrl) { throw new Error( diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts index 0a4ac805a9..cf057c10b1 100644 --- a/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts +++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/provider.ts @@ -16,6 +16,7 @@ import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm' import { createOpenRouter } from '@openrouter/ai-sdk-provider' import type { LanguageModel } from 'ai' import { logger } from '../../logger' +import { createCodexFetch } from '../oauth/codex-fetch' import { createOpenRouterCompatibleFetch } from '../../openrouter-fetch' import type { ResolvedLLMConfig } from './types' @@ -134,6 +135,15 @@ function createMoonshotModel(config: ResolvedLLMConfig): LanguageModel { })(config.model) } +function createChatGPTProModel(config: ResolvedLLMConfig): LanguageModel { + if (!config.apiKey) + throw new Error('ChatGPT Pro requires OAuth authentication') + return createOpenAI({ + apiKey: config.apiKey, + fetch: createCodexFetch(config.accountId) as typeof globalThis.fetch, + }).responses(config.model) +} + const PROVIDER_FACTORIES: Record = { [LLM_PROVIDERS.ANTHROPIC]: createAnthropicModel, [LLM_PROVIDERS.OPENAI]: createOpenAIModel, @@ -146,6 +156,7 @@ const PROVIDER_FACTORIES: Record = { [LLM_PROVIDERS.BROWSEROS]: createBrowserOSModel, [LLM_PROVIDERS.OPENAI_COMPATIBLE]: createOpenAICompatibleModel, [LLM_PROVIDERS.MOONSHOT]: createMoonshotModel, + [LLM_PROVIDERS.CHATGPT_PRO]: createChatGPTProModel, } export function createLLMProvider(config: ResolvedLLMConfig): LanguageModel { diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/refine-prompt.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/refine-prompt.ts index a1b71907ca..f0950147b5 100644 --- a/packages/browseros-agent/apps/server/src/lib/clients/llm/refine-prompt.ts +++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/refine-prompt.ts @@ -1,6 +1,6 @@ import { TIMEOUTS } from '@browseros/shared/constants/timeouts' import type { LLMConfig } from '@browseros/shared/schemas/llm' -import { generateText } from 'ai' +import { streamText } from 'ai' import { resolveLLMConfig } from './config' import { createLLMProvider } from './provider' @@ -38,18 +38,21 @@ Write it as a natural instruction — like telling a capable assistant what to d export async function refinePrompt( llmConfig: RefinePromptConfig, request: RefinePromptRequest, + browserosId?: string, ): Promise { try { - const resolvedConfig = await resolveLLMConfig(llmConfig) + const resolvedConfig = await resolveLLMConfig(llmConfig, browserosId) const model = createLLMProvider(resolvedConfig) - const response = await generateText({ + + // streamText works for all providers including Codex (which requires streaming) + const stream = streamText({ model, system: buildSystemPrompt(request.name), messages: [{ role: 'user', content: request.prompt }], abortSignal: AbortSignal.timeout(TIMEOUTS.REFINE_PROMPT), }) + const refined = (await stream.text)?.trim() - const refined = response.text?.trim() if (!refined) { return { success: false, message: 'Provider returned an empty response' } } diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/test-provider.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/test-provider.ts index a0745996c7..04d017a178 100644 --- a/packages/browseros-agent/apps/server/src/lib/clients/llm/test-provider.ts +++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/test-provider.ts @@ -6,7 +6,7 @@ import { TIMEOUTS } from '@browseros/shared/constants/timeouts' import type { LLMConfig } from '@browseros/shared/schemas/llm' -import { generateText } from 'ai' +import { streamText } from 'ai' import { resolveLLMConfig } from './config' import { createLLMProvider } from './provider' @@ -25,20 +25,22 @@ const TEST_PROMPT = "Respond with exactly: 'ok'" export async function testProviderConnection( config: ProviderTestConfig, + browserosId?: string, ): Promise { const startTime = performance.now() try { - const resolvedConfig = await resolveLLMConfig(config) + const resolvedConfig = await resolveLLMConfig(config, browserosId) const model = createLLMProvider(resolvedConfig) - const response = await generateText({ + + // streamText works for all providers including Codex (which requires streaming) + const stream = streamText({ model, messages: [{ role: 'user', content: TEST_PROMPT }], abortSignal: AbortSignal.timeout(TIMEOUTS.TEST_PROVIDER), }) - + const text = await stream.text const responseTime = Math.round(performance.now() - startTime) - const text = response.text if (text) { const preview = text.length > 100 ? `${text.slice(0, 100)}...` : text diff --git a/packages/browseros-agent/apps/server/src/lib/clients/llm/types.ts b/packages/browseros-agent/apps/server/src/lib/clients/llm/types.ts index d40a093707..0374e75fc7 100644 --- a/packages/browseros-agent/apps/server/src/lib/clients/llm/types.ts +++ b/packages/browseros-agent/apps/server/src/lib/clients/llm/types.ts @@ -11,4 +11,5 @@ import type { LLMConfig } from '@browseros/shared/schemas/llm' export interface ResolvedLLMConfig extends LLMConfig { model: string upstreamProvider?: string + accountId?: string } diff --git a/packages/browseros-agent/apps/server/src/lib/clients/oauth/callback-server.ts b/packages/browseros-agent/apps/server/src/lib/clients/oauth/callback-server.ts new file mode 100644 index 0000000000..acfd380ec5 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/clients/oauth/callback-server.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Temporary HTTP server on port 1455 for OAuth callbacks. + * OpenAI's OAuth requires redirect_uri to use this specific port + * (matching the Codex CLI client ID registration). + */ + +import { OAUTH_CALLBACK_PORT } from '@browseros/shared/constants/ports' +import { logger } from '../../logger' +import type { OAuthTokenManager } from './token-manager' + +export function startOAuthCallbackServer( + tokenManager: OAuthTokenManager, +): { stop: () => void } { + const server = Bun.serve({ + port: OAUTH_CALLBACK_PORT, + hostname: '127.0.0.1', + fetch: async (req) => { + const url = new URL(req.url) + if (url.pathname !== '/auth/callback') { + return new Response('Not found', { status: 404 }) + } + + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const error = url.searchParams.get('error') + + if (error) { + const description = + url.searchParams.get('error_description') || error + logger.warn('OAuth callback received error', { error, description }) + return htmlResponse(errorPage(description)) + } + + if (!code || !state) { + return htmlResponse(errorPage('Missing authorization code or state')) + } + + try { + await tokenManager.handleCallback(code, state) + + // Always show success page — chrome-extension:// redirects are blocked by Chromium. + // The extension polls /oauth/:provider/status and detects auth automatically. + return htmlResponse(successPage()) + } catch (err) { + logger.error('OAuth callback failed', { + error: err instanceof Error ? err.message : String(err), + }) + return htmlResponse( + errorPage( + err instanceof Error ? err.message : 'Authentication failed', + ), + ) + } + }, + }) + + logger.info('OAuth callback server started', { port: OAUTH_CALLBACK_PORT }) + + return { + stop: () => { + server.stop() + logger.info('OAuth callback server stopped') + }, + } +} + +function htmlResponse(html: string): Response { + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }) +} + +function successPage(): string { + return ` +BrowserOS - Authentication Successful + +

Authentication Successful

You can close this tab and return to BrowserOS.

` +} + +function errorPage(message: string): string { + const escaped = message + .replace(/&/g, '&') + .replace(//g, '>') + return ` +BrowserOS - Authentication Failed + +

Authentication Failed

${escaped}

Please close this tab and try again.

` +} diff --git a/packages/browseros-agent/apps/server/src/lib/clients/oauth/codex-fetch.ts b/packages/browseros-agent/apps/server/src/lib/clients/oauth/codex-fetch.ts new file mode 100644 index 0000000000..d4ac4cb19f --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/clients/oauth/codex-fetch.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { logger } from '../../logger' + +const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses' + +export function createCodexFetch(accountId?: string) { + return (input: RequestInfo | URL, init?: RequestInit) => { + let inputUrl: string + if (typeof input === 'string') { + inputUrl = input + } else if (input instanceof URL) { + inputUrl = input.toString() + } else if (input instanceof Request) { + inputUrl = input.url + } else { + inputUrl = String(input) + } + + const parsed = new URL(inputUrl) + const shouldRewrite = + parsed.pathname.includes('/v1/responses') || + parsed.pathname.includes('/chat/completions') + const url = shouldRewrite ? new URL(CODEX_API_ENDPOINT) : parsed + + const headers = new Headers(init?.headers as HeadersInit) + if (accountId) { + headers.set('ChatGPT-Account-Id', accountId) + } + headers.set('originator', 'browseros') + headers.set('OpenAI-Beta', 'responses=experimental') + + let body = init?.body + if (shouldRewrite && body && typeof body === 'string') { + try { + const json = JSON.parse(body) + json.stream = true + json.store = false + delete json.previous_response_id + delete json.temperature + delete json.max_tokens + delete json.max_output_tokens + delete json.top_p + if (!json.instructions) { + json.instructions = 'You are a helpful assistant.' + } + // Strip item IDs — Codex doesn't persist items with store=false. + // The SDK should already inline content (via providerOptions store=false), + // but this is a safety net matching OpenCode's approach. + if (Array.isArray(json.input)) { + for (const item of json.input) { + if ('id' in item) { + delete item.id + } + } + } + body = JSON.stringify(json) + } catch (err) { + logger.warn('Failed to inject Codex-required fields into request body', { + error: err instanceof Error ? err.message : String(err), + }) + } + } + + return fetch(url, { ...init, headers, body }) + } +} diff --git a/packages/browseros-agent/apps/server/src/lib/clients/oauth/index.ts b/packages/browseros-agent/apps/server/src/lib/clients/oauth/index.ts new file mode 100644 index 0000000000..a53e8880bb --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/clients/oauth/index.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Database } from 'bun:sqlite' +import { startOAuthCallbackServer } from './callback-server' +import { OAuthTokenManager } from './token-manager' +import { OAuthTokenStore } from './token-store' + +let tokenManager: OAuthTokenManager | null = null + +export function initializeOAuth( + db: Database, + browserosId: string, +): OAuthTokenManager { + const store = new OAuthTokenStore(db) + tokenManager = new OAuthTokenManager(store, browserosId) + startOAuthCallbackServer(tokenManager) + return tokenManager +} + +export function getOAuthTokenManager(): OAuthTokenManager | null { + return tokenManager +} diff --git a/packages/browseros-agent/apps/server/src/lib/clients/oauth/providers.ts b/packages/browseros-agent/apps/server/src/lib/clients/oauth/providers.ts new file mode 100644 index 0000000000..1270240462 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/clients/oauth/providers.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { EXTERNAL_URLS } from '@browseros/shared/constants/urls' + +export interface OAuthProviderConfig { + id: string + name: string + clientId: string + authEndpoint: string + tokenEndpoint: string + scopes: string[] + extraAuthParams?: Record + upstreamLLMProvider: string +} + +export const OAUTH_PROVIDERS: Record = { + 'chatgpt-pro': { + id: 'chatgpt-pro', + name: 'ChatGPT Pro', + clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', + authEndpoint: EXTERNAL_URLS.OPENAI_AUTH, + tokenEndpoint: EXTERNAL_URLS.OPENAI_TOKEN, + scopes: ['openid', 'profile', 'email', 'offline_access'], + extraAuthParams: { + id_token_add_organizations: 'true', + codex_cli_simplified_flow: 'true', + originator: 'browseros', + }, + upstreamLLMProvider: 'openai', + }, +} + +export function getOAuthProvider( + providerId: string, +): OAuthProviderConfig | undefined { + return OAUTH_PROVIDERS[providerId] +} diff --git a/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-manager.ts b/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-manager.ts new file mode 100644 index 0000000000..dbcc8510bc --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-manager.ts @@ -0,0 +1,279 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { OAUTH_CALLBACK_PORT } from '@browseros/shared/constants/ports' +import { TIMEOUTS } from '@browseros/shared/constants/timeouts' +import { logger } from '../../logger' +import { getOAuthProvider } from './providers' +import type { OAuthTokenStore, StoredOAuthTokens } from './token-store' + +interface PendingOAuthFlow { + provider: string + codeVerifier: string + state: string + redirectBackUrl?: string + createdAt: number +} + +interface OAuthTokenResponse { + access_token: string + refresh_token?: string + expires_in: number + id_token?: string +} + +export class OAuthTokenManager { + private readonly pendingFlows = new Map() + private readonly refreshLocks = new Map>() + + constructor( + private readonly store: OAuthTokenStore, + private readonly browserosId: string, + ) {} + + async generateAuthorizationUrl( + providerId: string, + redirectBackUrl?: string, + ): Promise { + const provider = getOAuthProvider(providerId) + if (!provider) throw new Error(`Unknown OAuth provider: ${providerId}`) + + const codeVerifier = generateCodeVerifier() + const codeChallenge = await generateCodeChallenge(codeVerifier) + const state = generateRandomState() + + this.pendingFlows.set(state, { + provider: providerId, + codeVerifier, + state, + redirectBackUrl, + createdAt: Date.now(), + }) + this.cleanExpiredFlows() + + const redirectUri = buildRedirectUri() + const params = new URLSearchParams({ + response_type: 'code', + client_id: provider.clientId, + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + scope: provider.scopes.join(' '), + state, + ...provider.extraAuthParams, + }) + + return `${provider.authEndpoint}?${params.toString()}` + } + + async handleCallback( + code: string, + state: string, + ): Promise<{ tokens: StoredOAuthTokens; redirectBackUrl?: string }> { + const flow = this.pendingFlows.get(state) + if (!flow) throw new Error('Invalid or expired OAuth state') + if (Date.now() - flow.createdAt > TIMEOUTS.OAUTH_FLOW_TTL) { + this.pendingFlows.delete(state) + throw new Error('OAuth flow expired. Please try again.') + } + + const provider = getOAuthProvider(flow.provider) + if (!provider) throw new Error(`Unknown OAuth provider: ${flow.provider}`) + + const redirectUri = buildRedirectUri() + const tokenResponse = await fetch(provider.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: provider.clientId, + code, + redirect_uri: redirectUri, + code_verifier: flow.codeVerifier, + }), + }) + + if (!tokenResponse.ok) { + const error = await tokenResponse.text() + logger.error('OAuth token exchange failed', { + status: tokenResponse.status, + error, + }) + throw new Error(`Token exchange failed: ${tokenResponse.status}`) + } + + const data = (await tokenResponse.json()) as OAuthTokenResponse + if (!data.refresh_token) { + logger.warn('OAuth token response missing refresh_token — token refresh will not be available', { + provider: flow.provider, + }) + } + const { accountId, email } = parseAccessTokenClaims(data.access_token) + + const tokens: StoredOAuthTokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? '', + expiresAt: Date.now() + data.expires_in * 1000, + email, + accountId, + } + + this.store.upsertTokens(this.browserosId, flow.provider, tokens) + this.pendingFlows.delete(state) + + logger.info('OAuth authentication successful', { + provider: flow.provider, + email, + }) + + return { tokens, redirectBackUrl: flow.redirectBackUrl } + } + + // Mutex-protected refresh: concurrent callers share one in-flight refresh + async refreshIfExpired(provider: string): Promise { + const tokens = this.store.getTokens(this.browserosId, provider) + if (!tokens) return null + + if (Date.now() < tokens.expiresAt - TIMEOUTS.OAUTH_TOKEN_EXPIRY_BUFFER) { + return tokens + } + + // If a refresh is already in progress, await it instead of starting another + const existing = this.refreshLocks.get(provider) + if (existing) return existing + + const refreshPromise = this.executeRefresh(provider, tokens) + this.refreshLocks.set(provider, refreshPromise) + + try { + return await refreshPromise + } finally { + this.refreshLocks.delete(provider) + } + } + + private async executeRefresh( + provider: string, + tokens: StoredOAuthTokens, + ): Promise { + if (!tokens.refreshToken) { + this.store.deleteTokens(this.browserosId, provider) + throw new Error(`${provider} session expired (no refresh token). Please re-login.`) + } + + const providerConfig = getOAuthProvider(provider) + if (!providerConfig) { + throw new Error(`Unknown OAuth provider: ${provider}`) + } + + logger.debug('Refreshing OAuth token', { provider }) + + const response = await fetch(providerConfig.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: providerConfig.clientId, + refresh_token: tokens.refreshToken, + }), + }) + + if (!response.ok) { + logger.error('OAuth token refresh failed', { + provider, + status: response.status, + }) + this.store.deleteTokens(this.browserosId, provider) + const providerName = providerConfig.name + throw new Error(`${providerName} session expired. Please re-login.`) + } + + const data = (await response.json()) as OAuthTokenResponse + const { accountId, email } = parseAccessTokenClaims(data.access_token) + + const refreshed: StoredOAuthTokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? tokens.refreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + email: email ?? tokens.email, + accountId: accountId ?? tokens.accountId, + } + + this.store.upsertTokens(this.browserosId, provider, refreshed) + return refreshed + } + + getStatus(provider: string) { + return this.store.getStatus(this.browserosId, provider) + } + + deleteTokens(provider: string): void { + this.store.deleteTokens(this.browserosId, provider) + } + + private cleanExpiredFlows(): void { + const now = Date.now() + for (const [state, flow] of this.pendingFlows) { + if (now - flow.createdAt > TIMEOUTS.OAUTH_FLOW_TTL) { + this.pendingFlows.delete(state) + } + } + } +} + +function buildRedirectUri(): string { + return `http://localhost:${OAUTH_CALLBACK_PORT}/auth/callback` +} + +function generateCodeVerifier(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)) + return base64UrlEncode(bytes) +} + +async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder() + const digest = await crypto.subtle.digest('SHA-256', encoder.encode(verifier)) + return base64UrlEncode(new Uint8Array(digest)) +} + +function generateRandomState(): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)) + return base64UrlEncode(bytes) +} + +function base64UrlEncode(bytes: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...bytes)) + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +// Extracts claims without signature verification — safe because the token +// comes directly from OpenAI's HTTPS token endpoint. Do not reuse for +// caller-supplied or externally-sourced tokens. +function parseAccessTokenClaims(accessToken: string): { + accountId?: string + email?: string +} { + try { + const parts = accessToken.split('.') + if (parts.length !== 3) return {} + const payload = JSON.parse( + atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')), + ) + const authClaims = payload['https://api.openai.com/auth'] + const profileClaims = payload['https://api.openai.com/profile'] + return { + accountId: + authClaims?.chatgpt_account_id ?? + payload.chatgpt_account_id ?? + payload.account_id, + email: + profileClaims?.email ?? + payload.email, + } + } catch { + return {} + } +} diff --git a/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-store.ts b/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-store.ts new file mode 100644 index 0000000000..9c0f344081 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/lib/clients/oauth/token-store.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * SQLite storage for OAuth tokens. + */ + +import type { Database } from 'bun:sqlite' + +export interface StoredOAuthTokens { + accessToken: string + refreshToken: string + expiresAt: number + email?: string + accountId?: string +} + +export interface OAuthStatus { + authenticated: boolean + email?: string + provider: string +} + +export class OAuthTokenStore { + constructor(private readonly db: Database) {} + + upsertTokens( + browserosId: string, + provider: string, + tokens: StoredOAuthTokens, + ): void { + const stmt = this.db.prepare(` + INSERT INTO oauth_tokens (browseros_id, provider, access_token, refresh_token, expires_at, email, account_id, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT (browseros_id, provider) DO UPDATE SET + access_token = excluded.access_token, + refresh_token = excluded.refresh_token, + expires_at = excluded.expires_at, + email = excluded.email, + account_id = excluded.account_id, + updated_at = datetime('now') + `) + stmt.run( + browserosId, + provider, + tokens.accessToken, + tokens.refreshToken, + tokens.expiresAt, + tokens.email ?? null, + tokens.accountId ?? null, + ) + } + + getTokens(browserosId: string, provider: string): StoredOAuthTokens | null { + const row = this.db + .prepare( + 'SELECT access_token, refresh_token, expires_at, email, account_id FROM oauth_tokens WHERE browseros_id = ? AND provider = ?', + ) + .get(browserosId, provider) as { + access_token: string + refresh_token: string + expires_at: number + email: string | null + account_id: string | null + } | null + + if (!row) return null + return { + accessToken: row.access_token, + refreshToken: row.refresh_token, + expiresAt: row.expires_at, + email: row.email ?? undefined, + accountId: row.account_id ?? undefined, + } + } + + deleteTokens(browserosId: string, provider: string): void { + this.db + .prepare( + 'DELETE FROM oauth_tokens WHERE browseros_id = ? AND provider = ?', + ) + .run(browserosId, provider) + } + + getStatus(browserosId: string, provider: string): OAuthStatus { + const row = this.db + .prepare( + 'SELECT email FROM oauth_tokens WHERE browseros_id = ? AND provider = ?', + ) + .get(browserosId, provider) as { email: string | null } | null + + return { + authenticated: row !== null, + email: row?.email ?? undefined, + provider, + } + } +} diff --git a/packages/browseros-agent/apps/server/src/lib/db/schema.ts b/packages/browseros-agent/apps/server/src/lib/db/schema.ts index 2e0412c11b..2222cbd8d9 100644 --- a/packages/browseros-agent/apps/server/src/lib/db/schema.ts +++ b/packages/browseros-agent/apps/server/src/lib/db/schema.ts @@ -21,7 +21,22 @@ CREATE TABLE IF NOT EXISTS identity ( created_at TEXT NOT NULL DEFAULT (datetime('now')) )` +const OAUTH_TOKENS_TABLE = ` +CREATE TABLE IF NOT EXISTS oauth_tokens ( + browseros_id TEXT NOT NULL, + provider TEXT NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + expires_at INTEGER NOT NULL, + email TEXT, + account_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (browseros_id, provider) +)` + export function initSchema(db: Database): void { db.exec(RATE_LIMITER_TABLE) db.exec(IDENTITY_TABLE) + db.exec(OAUTH_TOKENS_TABLE) } diff --git a/packages/browseros-agent/packages/shared/src/constants/ports.ts b/packages/browseros-agent/packages/shared/src/constants/ports.ts index 27d9780f5a..729e59ba5f 100644 --- a/packages/browseros-agent/packages/shared/src/constants/ports.ts +++ b/packages/browseros-agent/packages/shared/src/constants/ports.ts @@ -40,4 +40,7 @@ export const DEV_PORTS = { extension: 9310, } as const +// OAuth callback port — must match the redirect_uri registered with OpenAI's Codex client ID +export const OAUTH_CALLBACK_PORT = 1455 + export type Ports = typeof DEFAULT_PORTS diff --git a/packages/browseros-agent/packages/shared/src/constants/timeouts.ts b/packages/browseros-agent/packages/shared/src/constants/timeouts.ts index b54f5004c1..4436bf58fd 100644 --- a/packages/browseros-agent/packages/shared/src/constants/timeouts.ts +++ b/packages/browseros-agent/packages/shared/src/constants/timeouts.ts @@ -48,6 +48,12 @@ export const TIMEOUTS = { WS_HEARTBEAT_TIMEOUT: 5_000, WS_CONNECTION_TIMEOUT: 10_000, WS_REQUEST_TIMEOUT: 30_000, + + // OAuth + OAUTH_FLOW_TTL: 300_000, + OAUTH_TOKEN_EXPIRY_BUFFER: 300_000, + OAUTH_POLL_INTERVAL: 2_000, + OAUTH_POLL_TIMEOUT: 300_000, } as const export type TimeoutKey = keyof typeof TIMEOUTS diff --git a/packages/browseros-agent/packages/shared/src/constants/urls.ts b/packages/browseros-agent/packages/shared/src/constants/urls.ts index b31066bb39..2995e09552 100644 --- a/packages/browseros-agent/packages/shared/src/constants/urls.ts +++ b/packages/browseros-agent/packages/shared/src/constants/urls.ts @@ -10,5 +10,7 @@ export const EXTERNAL_URLS = { KLAVIS_PROXY: 'https://llm.browseros.com/klavis', POSTHOG_DEFAULT: 'https://us.i.posthog.com', CODEGEN_SERVICE: 'https://graph.browseros.com', + OPENAI_AUTH: 'https://auth.openai.com/oauth/authorize', + OPENAI_TOKEN: 'https://auth.openai.com/oauth/token', SKILLS_CATALOG: 'https://cdn.browseros.com/skills/v1/catalog.json', } as const diff --git a/packages/browseros-agent/packages/shared/src/schemas/llm.ts b/packages/browseros-agent/packages/shared/src/schemas/llm.ts index 17befcd829..01f2acb989 100644 --- a/packages/browseros-agent/packages/shared/src/schemas/llm.ts +++ b/packages/browseros-agent/packages/shared/src/schemas/llm.ts @@ -24,6 +24,7 @@ export const LLM_PROVIDERS = { BROWSEROS: 'browseros', OPENAI_COMPATIBLE: 'openai-compatible', MOONSHOT: 'moonshot', + CHATGPT_PRO: 'chatgpt-pro', } as const /** @@ -42,6 +43,7 @@ export const LLMProviderSchema: z.ZodEnum< 'browseros', 'openai-compatible', 'moonshot', + 'chatgpt-pro', ] > = z.enum([ LLM_PROVIDERS.ANTHROPIC, @@ -55,6 +57,7 @@ export const LLMProviderSchema: z.ZodEnum< LLM_PROVIDERS.BROWSEROS, LLM_PROVIDERS.OPENAI_COMPATIBLE, LLM_PROVIDERS.MOONSHOT, + LLM_PROVIDERS.CHATGPT_PRO, ]) export type LLMProvider = z.infer @@ -73,6 +76,8 @@ export const LLMConfigSchema: z.ZodObject<{ accessKeyId: z.ZodOptional secretAccessKey: z.ZodOptional sessionToken: z.ZodOptional + reasoningEffort: z.ZodOptional> + reasoningSummary: z.ZodOptional> }> = z.object({ provider: LLMProviderSchema, model: z.string().optional(), @@ -85,6 +90,9 @@ export const LLMConfigSchema: z.ZodObject<{ accessKeyId: z.string().optional(), secretAccessKey: z.string().optional(), sessionToken: z.string().optional(), + // ChatGPT Pro (Codex) + reasoningEffort: z.enum(['none', 'low', 'medium', 'high']).optional(), + reasoningSummary: z.enum(['auto', 'concise', 'detailed']).optional(), }) export type LLMConfig = z.infer