From a1db0e395af83a83a71af3d51160544c42edbb5c Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Wed, 15 Jul 2026 11:00:01 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Sign=20in=20with=20ChatGPT=20?= =?UTF-8?q?=E2=80=94=20OAuth=20sign-in,=20token=20storage,=20and=20setting?= =?UTF-8?q?s=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets users connect their ChatGPT Plus/Pro subscription instead of pasting an API key. Sign-in half only: the codex model client consumes the session via getChatGPTAccessToken(). - PKCE OAuth against auth.openai.com using the official Codex CLI public client id (constants verified against the openai/codex sources); system browser + loopback callback server on the pre-registered fixed port 127.0.0.1:1455, state validated, stale callbacks rejected - token store (core): single config/chatgpt-auth.json holding non-secret display fields (email, accountId, expiresAt) plus the token material encrypted via the safeStorage cipher bridge (plaintext-with-marker fallback when no keychain, matching the GitHub token path); no token value ever written in the clear or logged - getChatGPTAccessToken(): single-flight refresh within 5 min of expiry; refresh rejection (400/401) clears to a clean signed-out state and throws typed ChatGPTAuthRequiredError; 5xx/network kept as transient - sign-out with best-effort token revocation - IPC chatgpt:getStatus/signIn/cancelSignIn/signOut — raw tokens never cross to the renderer; real cancellation settles the in-flight attempt, frees the port, and a retry starts a fresh attempt (new PKCE/state) - settings UI: ChatGPT Subscription section on the OpenAI card (sign in / waiting + cancel / connected as {email} + sign out) via a useChatGPT hook Vitest covers the token store, single-flight refresh, and transient-vs-terminal refresh handling. --- apps/x/apps/main/src/auth-server.ts | 89 ++-- apps/x/apps/main/src/chatgpt-signin.ts | 247 +++++++++++ apps/x/apps/main/src/ipc.ts | 21 + apps/x/apps/main/src/main.ts | 7 + .../src/components/settings-dialog.tsx | 51 +++ apps/x/apps/renderer/src/hooks/useChatGPT.ts | 114 +++++ .../core/src/auth/chatgpt-auth.test.ts | 352 ++++++++++++++++ apps/x/packages/core/src/auth/chatgpt-auth.ts | 389 ++++++++++++++++++ .../core/src/auth/chatgpt-constants.ts | 97 +++++ apps/x/packages/shared/src/ipc.ts | 38 ++ 10 files changed, 1382 insertions(+), 23 deletions(-) create mode 100644 apps/x/apps/main/src/chatgpt-signin.ts create mode 100644 apps/x/apps/renderer/src/hooks/useChatGPT.ts create mode 100644 apps/x/packages/core/src/auth/chatgpt-auth.test.ts create mode 100644 apps/x/packages/core/src/auth/chatgpt-auth.ts create mode 100644 apps/x/packages/core/src/auth/chatgpt-constants.ts diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index 5c46ca3f..b1e93d67 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -20,9 +20,46 @@ export interface AuthServerResult { port: number; } +interface CallbackHandlingOpts { + callbackPath: string; + /** Invoked when the provider redirects back with an `error` param. */ + onError?: (error: string) => void; + /** + * Gatekeeper run BEFORE the error/callback handling. Return a message to + * reject the request with a polite close-this-tab error page — without + * invoking onCallback/onError. Lets a caller drop stale callbacks (e.g. the + * browser tab of a cancelled sign-in attempt carrying an old `state`) + * without disturbing the live flow. + */ + validateCallback?: (url: URL) => string | null; +} + +function renderErrorPage(res: import('http').ServerResponse, message: string): void { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + + + OAuth Error + + + +

Authorization Failed

+

${escapeHtml(message)}

+

You can close this window.

+ + + + `); +} + function tryBindPort( port: number, - onCallback: (callbackUrl: URL) => void | Promise + onCallback: (callbackUrl: URL) => void | Promise, + opts: CallbackHandlingOpts, ): Promise { return new Promise((resolve, reject) => { const server = createServer((req, res) => { @@ -34,29 +71,24 @@ function tryBindPort( const url = new URL(req.url, `http://localhost:${port}`); - if (url.pathname === OAUTH_CALLBACK_PATH) { + if (url.pathname === opts.callbackPath) { + // Gatekeeper first: stale/foreign requests must not reach onError or + // onCallback (a stale tab's redirect must never settle a live flow). + const rejection = opts.validateCallback?.(url) ?? null; + if (rejection) { + renderErrorPage(res, rejection); + return; + } + const error = url.searchParams.get('error'); if (error) { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(` - - - - OAuth Error - - - -

Authorization Failed

-

Error: ${escapeHtml(error)}

-

You can close this window.

- - - - `); + // Surface the provider error (e.g. access_denied when the user + // cancels consent) so the caller can settle its flow instead of + // waiting for the timeout. Callers that don't opt in keep the old + // behaviour: the error page renders and the flow times out. + opts.onError?.(error); + renderErrorPage(res, `Error: ${error}`); return; } @@ -117,18 +149,29 @@ function tryBindPort( * through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling * both EADDRINUSE and EACCES (the latter is common on Windows when * Hyper-V/WSL2 reserve the port). + * + * `callbackPath` overrides the served path for providers whose registered + * redirect URI differs (ChatGPT/Codex uses /auth/callback). `onError` is + * invoked when the provider redirects back with an `error` param (e.g. + * access_denied), letting the caller settle instead of waiting for timeout. + * `validateCallback` runs before both — see CallbackHandlingOpts. */ export async function createAuthServer( port: number = DEFAULT_PORT, onCallback: (callbackUrl: URL) => void | Promise, - opts: { fallback?: boolean } = {}, + opts: { fallback?: boolean } & Partial = {}, ): Promise { const fallback = opts.fallback === true; + const handlingOpts: CallbackHandlingOpts = { + callbackPath: opts.callbackPath ?? OAUTH_CALLBACK_PATH, + onError: opts.onError, + validateCallback: opts.validateCallback, + }; const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port; for (let p = port; p <= limit; p++) { try { - return await tryBindPort(p, onCallback); + return await tryBindPort(p, onCallback, handlingOpts); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) { diff --git a/apps/x/apps/main/src/chatgpt-signin.ts b/apps/x/apps/main/src/chatgpt-signin.ts new file mode 100644 index 00000000..81e789d6 --- /dev/null +++ b/apps/x/apps/main/src/chatgpt-signin.ts @@ -0,0 +1,247 @@ +import { shell } from 'electron'; +import type { Server } from 'http'; +import { createAuthServer } from './auth-server.js'; +import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; +import { exchangeChatGPTCode, getChatGPTStatus } from '@x/core/dist/auth/chatgpt-auth.js'; +import { + CHATGPT_AUTHORIZE_URL, + CHATGPT_CALLBACK_PATH, + CHATGPT_CALLBACK_PORT, + CHATGPT_CLIENT_ID, + CHATGPT_EXTRA_AUTHORIZE_PARAMS, + CHATGPT_REDIRECT_URI, + CHATGPT_SCOPES, +} from '@x/core/dist/auth/chatgpt-constants.js'; + +// Interactive "Sign in with ChatGPT" flow (OAuth 2.0 + PKCE, Codex CLI client +// — see chatgpt-constants.ts). Orchestration only: PKCE/state generation and +// all token-endpoint traffic + storage live in core; this module owns the +// system browser, the loopback callback server on 127.0.0.1:1455, and flow +// lifecycle. The port is FIXED — the redirect URI is pre-registered at OpenAI +// for the Codex client id, so there is no scan-to-next-port fallback. + +export type ChatGPTSignInResult = { + signedIn: boolean; + email?: string; + accountId?: string; + /** True when the attempt was cancelled (Cancel button or superseded). */ + cancelled?: boolean; + error?: string; +}; + +/** Generous, mirrors the Google flow's abandoned-flow cleanup ceiling. */ +const SIGN_IN_TIMEOUT_MS = 5 * 60 * 1000; + +type ActiveAttempt = { + promise: Promise; + /** + * Settle the attempt with a cancelled outcome. Resolves once the loopback + * server has fully closed (listener + keep-alive connections), so a + * follow-up attempt can rebind 1455 immediately. + */ + cancel: (reason: string) => Promise; +}; + +let activeAttempt: ActiveAttempt | null = null; + +/** + * Start a sign-in attempt. If one is already pending it is stale by + * definition — the user is clicking Sign In again precisely because no + * browser flow is visibly in progress (e.g. they closed the tab and hit + * Cancel) — so cancel it and start FRESH (new PKCE verifier/state, new + * loopback server, new browser window). Awaiting the cancel preserves the + * one-server-at-a-time invariant: 1455 is fully released before rebinding. + */ +export async function signInWithChatGPT(): Promise { + if (activeAttempt) { + const stale = activeAttempt; + activeAttempt = null; + console.log('[ChatGPTAuth] Cancelling stale sign-in attempt before starting a new one'); + await stale.cancel('Superseded by a new sign-in attempt.'); + } + + const attempt = startAttempt(); + activeAttempt = attempt; + void attempt.promise.finally(() => { + if (activeAttempt === attempt) activeAttempt = null; + }); + return attempt.promise; +} + +/** + * Abort the pending attempt (renderer Cancel button): stops the loopback + * server, clears pending state, settles the in-flight signIn promise with a + * cancelled outcome. No-op when nothing is pending. Never touches stored + * tokens — after cancel, chatgpt:getStatus reports signed-out (unless an + * earlier sign-in already completed). + */ +export async function cancelChatGPTSignIn(): Promise { + const attempt = activeAttempt; + if (!attempt) return; + activeAttempt = null; + await attempt.cancel('Sign-in cancelled.'); +} + +/** + * One sign-in attempt. The returned promise always RESOLVES (never rejects), + * and every exit path — success, denial, timeout, port busy, exchange + * failure, cancellation — tears down the loopback server and the timeout + * exactly once via the settle-once `finish`. + */ +function startAttempt(): ActiveAttempt { + let settle!: (result: ChatGPTSignInResult) => void; + const promise = new Promise((resolve) => { + settle = resolve; + }); + + let settled = false; + let server: Server | null = null; + let timeoutHandle: NodeJS.Timeout | null = null; + let serverClosed: Promise | null = null; + + // Close the listening socket AND any keep-alive connections (the browser + // holds one open after the callback response) so 1455 frees immediately. + const closeServer = (): Promise => { + if (serverClosed) return serverClosed; + const s = server; + server = null; + serverClosed = !s + ? Promise.resolve() + : new Promise((resolve) => { + s.close(() => resolve()); + s.closeAllConnections(); + }); + return serverClosed; + }; + + const finish = (result: ChatGPTSignInResult): Promise => { + if (settled) return serverClosed ?? Promise.resolve(); + settled = true; + if (timeoutHandle) clearTimeout(timeoutHandle); + const closed = closeServer(); + if (!result.signedIn) { + console.log(`[ChatGPTAuth] Sign-in did not complete: ${result.error ?? 'unknown'}`); + } + settle(result); + return closed; + }; + + const cancel = (reason: string): Promise => + finish({ signedIn: false, cancelled: true, error: reason }); + + void run(); + return { promise, cancel }; + + async function run(): Promise { + console.log('[ChatGPTAuth] Starting sign-in flow...'); + try { + const { verifier, challenge } = await oauthClient.generatePKCE(); + const state = oauthClient.generateState(); + if (settled) return; // cancelled while generating PKCE — nothing bound yet + + // Guard against duplicate callbacks (browser may send multiple requests). + let callbackHandled = false; + const onCallback = async (callbackUrl: URL) => { + if (settled || callbackHandled) return; + callbackHandled = true; + try { + // State already verified by validateCallback below. + const code = callbackUrl.searchParams.get('code'); + if (!code) { + void finish({ signedIn: false, error: 'Sign-in failed: callback is missing the authorization code.' }); + return; + } + // Exchange + persistence live in core (never log token values). + await exchangeChatGPTCode(code, verifier); + const status = await getChatGPTStatus(); + console.log('[ChatGPTAuth] Sign-in complete'); + void finish({ ...status }); + } catch (error) { + console.error('[ChatGPTAuth] Token exchange failed:', error); + void finish({ + signedIn: false, + error: error instanceof Error ? error.message : 'Token exchange failed', + }); + } + }; + + // Bind the loopback server FIRST so a busy port fails fast, before any + // browser tab opens. Fixed port — createAuthServer's no-fallback error + // message tells the user to free the port. + let boundServer: Server; + try { + ({ server: boundServer } = await createAuthServer(CHATGPT_CALLBACK_PORT, onCallback, { + callbackPath: CHATGPT_CALLBACK_PATH, + onError: (error) => { + void finish({ + signedIn: false, + error: error === 'access_denied' + ? 'Sign-in was cancelled in the browser.' + : `Sign-in failed: ${error}`, + }); + }, + // Stale callbacks — a tab left over from an earlier, cancelled + // attempt carries that attempt's `state` — get a polite + // close-this-tab page and never reach onError/onCallback, so they + // can neither complete sign-in nor settle the live attempt. + validateCallback: (url) => { + if (settled) { + return 'This sign-in attempt is no longer active. Close this tab and try again from Rowboat.'; + } + if (url.searchParams.get('state') !== state) { + return 'This sign-in link has expired. Close this tab and try again from Rowboat.'; + } + return null; + }, + })); + } catch (error) { + void finish({ + signedIn: false, + error: error instanceof Error ? error.message : 'Failed to start the sign-in callback server', + }); + return; + } + + if (settled) { + // Cancelled while the bind was in flight — release the port we just + // grabbed (finish() already ran with no server to close). + boundServer.closeAllConnections(); + boundServer.close(); + return; + } + server = boundServer; + + timeoutHandle = setTimeout(() => { + void finish({ signedIn: false, error: 'Sign-in timed out. Please try again.' }); + }, SIGN_IN_TIMEOUT_MS); + + const authUrl = new URL(CHATGPT_AUTHORIZE_URL); + authUrl.search = new URLSearchParams({ + response_type: 'code', + client_id: CHATGPT_CLIENT_ID, + redirect_uri: CHATGPT_REDIRECT_URI, + scope: CHATGPT_SCOPES.join(' '), + code_challenge: challenge, + code_challenge_method: 'S256', + state, + ...CHATGPT_EXTRA_AUTHORIZE_PARAMS, + }).toString(); + + try { + // System browser: shares the user's existing ChatGPT session cookies. + await shell.openExternal(authUrl.toString()); + } catch (error) { + void finish({ + signedIn: false, + error: error instanceof Error ? `Failed to open browser: ${error.message}` : 'Failed to open browser', + }); + } + } catch (error) { + console.error('[ChatGPTAuth] Sign-in flow error:', error); + void finish({ + signedIn: false, + error: error instanceof Error ? error.message : 'Sign-in failed', + }); + } + } +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index d02e4f6e..3759a6a5 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -40,6 +40,8 @@ import { isSignedIn } from '@x/core/dist/account/account.js'; import { listGatewayModels } from '@x/core/dist/models/gateway.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; +import { getChatGPTStatus, signOutChatGPT } from '@x/core/dist/auth/chatgpt-auth.js'; +import { signInWithChatGPT, cancelChatGPTSignIn } from './chatgpt-signin.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js'; import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js'; @@ -1285,6 +1287,25 @@ export function setupIpcHandlers() { const config = await repo.getClientFacingConfig(); return { config }; }, + 'chatgpt:getStatus': async () => { + return await getChatGPTStatus(); + }, + 'chatgpt:signIn': async () => { + return await signInWithChatGPT(); + }, + 'chatgpt:cancelSignIn': async () => { + await cancelChatGPTSignIn(); + return { success: true }; + }, + 'chatgpt:signOut': async () => { + try { + await signOutChatGPT(); + return { success: true }; + } catch (error) { + console.error('[ChatGPTAuth] Sign-out failed:', error); + return { success: false }; + } + }, 'account:getRowboat': async () => { const signedIn = await isSignedIn(); if (!signedIn) { diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index cde05238..322f66eb 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -40,6 +40,7 @@ import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/runtime/asse import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js"; import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js"; import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js"; +import { setTokenCipher as setChatGPTTokenCipher } from "@x/core/dist/auth/chatgpt-auth.js"; import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js"; import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js"; import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js"; @@ -498,6 +499,12 @@ app.whenReady().then(async () => { encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'), decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')), }); + // ChatGPT subscription tokens at rest: same keychain-backed cipher. + setChatGPTTokenCipher({ + isAvailable: () => safeStorage.isEncryptionAvailable(), + encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'), + decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')), + }); initAppsServer().catch((error) => { console.error('[Apps] Failed to start:', error); }); diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 25386aae..cc881247 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -32,6 +32,7 @@ import type { ApprovalPolicy } from "@x/shared/src/code-mode.js" import type { ipc as ipcShared } from "@x/shared" import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning" import { useProviderModels } from "@/hooks/use-provider-models" +import { useChatGPT } from "@/hooks/useChatGPT" type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help" @@ -554,6 +555,9 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b apiKey: activeConfig.apiKey, baseURL: activeConfig.baseURL, }) + // "Sign in with ChatGPT" subscription state — only rendered on the OpenAI + // card; independent of the API-key providerConfigs state above. + const chatgpt = useChatGPT() const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible" const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway" @@ -1081,6 +1085,53 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b )} + {/* ChatGPT subscription — OAuth sign-in, independent of the API key and + of models.json (the Codex model client consumes the token via core) */} + {provider === "openai" && ( +
+ + ChatGPT Subscription + + {chatgpt.status.signedIn ? ( +
+
+ + + Connected as {chatgpt.status.email ?? "your ChatGPT account"} + +
+ +
+ ) : chatgpt.isSigningIn ? ( +
+
+ + Waiting for browser… +
+ +
+ ) : ( +
+ + Use your ChatGPT Plus/Pro subscription + + +
+ )} +
+ )} + {/* Base URL */} {showBaseURL && (
diff --git a/apps/x/apps/renderer/src/hooks/useChatGPT.ts b/apps/x/apps/renderer/src/hooks/useChatGPT.ts new file mode 100644 index 00000000..32571d1c --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useChatGPT.ts @@ -0,0 +1,114 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { toast } from 'sonner'; + +// "Sign in with ChatGPT" state, modeled on useOAuth.ts but against the +// dedicated chatgpt:* IPC surface: status is fetched on mount and re-derived +// from action results (chatgpt:signIn resolves with the final status — no +// broadcast event to listen for). + +type ChatGPTStatus = { + signedIn: boolean; + email?: string; + accountId?: string; +}; + +export function useChatGPT() { + const [status, setStatus] = useState({ signedIn: false }); + const [isLoading, setIsLoading] = useState(true); + const [isSigningIn, setIsSigningIn] = useState(false); + // Cancelled flag + attempt sequence: a cancelled or superseded attempt's + // invoke still resolves later (main settles it with `cancelled: true`); + // only the CURRENT attempt may touch isSigningIn or show toasts, so a + // stale resolution can't clobber a fresh attempt's waiting UI. + const cancelledRef = useRef(false); + const attemptSeqRef = useRef(0); + + const refresh = useCallback(async () => { + try { + setIsLoading(true); + setStatus(await window.ipc.invoke('chatgpt:getStatus', null)); + } catch (error) { + console.error('Failed to fetch ChatGPT status:', error); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const signIn = useCallback(async () => { + if (isSigningIn) return; + const attempt = ++attemptSeqRef.current; + cancelledRef.current = false; + setIsSigningIn(true); + // True only for the attempt whose result should drive the UI. + const isCurrent = () => attempt === attemptSeqRef.current && !cancelledRef.current; + try { + const result = await window.ipc.invoke('chatgpt:signIn', null); + if (result.signedIn) { + // Always reflect a successful sign-in, even if this attempt was + // cancelled client-side after the browser flow completed. + setStatus({ + signedIn: true, + ...(result.email ? { email: result.email } : {}), + ...(result.accountId ? { accountId: result.accountId } : {}), + }); + if (isCurrent()) { + toast.success(result.email ? `Signed in as ${result.email}` : 'Signed in with ChatGPT'); + } + } else if (isCurrent() && !result.cancelled) { + toast.error(result.error || 'ChatGPT sign-in failed'); + } + } catch (error) { + console.error('ChatGPT sign-in failed:', error); + if (isCurrent()) { + toast.error('ChatGPT sign-in failed'); + } + } finally { + if (isCurrent()) { + setIsSigningIn(false); + } + } + }, [isSigningIn]); + + const cancelSignIn = useCallback(() => { + cancelledRef.current = true; + setIsSigningIn(false); + // Really abort the main-process attempt (stops the loopback server and + // settles the pending signIn invoke) — otherwise the next Sign In click + // would join a dead attempt and never re-open the browser. + window.ipc.invoke('chatgpt:cancelSignIn', null).catch((error) => { + console.error('Failed to cancel ChatGPT sign-in:', error); + }); + }, []); + + const signOut = useCallback(async () => { + try { + setIsLoading(true); + const result = await window.ipc.invoke('chatgpt:signOut', null); + if (result.success) { + setStatus({ signedIn: false }); + toast.success('Signed out of ChatGPT'); + } else { + toast.error('Failed to sign out of ChatGPT'); + } + } catch (error) { + console.error('ChatGPT sign-out failed:', error); + toast.error('Failed to sign out of ChatGPT'); + } finally { + setIsLoading(false); + } + }, []); + + return { + status, + isLoading, + isSigningIn, + signIn, + cancelSignIn, + signOut, + refresh, + }; +} diff --git a/apps/x/packages/core/src/auth/chatgpt-auth.test.ts b/apps/x/packages/core/src/auth/chatgpt-auth.test.ts new file mode 100644 index 00000000..a33ed003 --- /dev/null +++ b/apps/x/packages/core/src/auth/chatgpt-auth.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Isolate the store from the real home dir BEFORE importing the module — +// WorkDir is resolved at config.ts import time (same pattern as +// classification_stamp.test.ts). +const tmpWorkDir = fs.mkdtempSync(path.join(os.tmpdir(), 'x-chatgpt-auth-test-')); +process.env.ROWBOAT_WORKDIR = tmpWorkDir; + +const chatgptAuth = await import('./chatgpt-auth.js'); +const { + saveChatGPTTokens, + getChatGPTAccessToken, + getChatGPTStatus, + signOutChatGPT, + setTokenCipher, + decodeJwtClaims, + ChatGPTAuthRequiredError, +} = chatgptAuth; +const { + CHATGPT_CLIENT_ID, + CHATGPT_TOKEN_URL, + CHATGPT_REVOKE_URL, + CHATGPT_AUTH_CLAIM_NAMESPACE, + CHATGPT_PROFILE_CLAIM_NAMESPACE, +} = await import('./chatgpt-constants.js'); + +const AUTH_FILE = path.join(tmpWorkDir, 'config', 'chatgpt-auth.json'); + +// Fixed clock so expiry math is deterministic. +const NOW_MS = new Date('2026-07-15T12:00:00Z').getTime(); +const NOW = Math.floor(NOW_MS / 1000); + +/** Unsigned JWT with the given payload — decodeJwtClaims only reads part [1]. */ +function makeJwt(claims: Record): string { + const b64 = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + return `${b64({ alg: 'none' })}.${b64(claims)}.sig`; +} + +function makeIdToken(overrides: Record = {}): string { + return makeJwt({ + email: 'user@example.com', + [CHATGPT_AUTH_CLAIM_NAMESPACE]: { chatgpt_account_id: 'acct_123' }, + ...overrides, + }); +} + +function makeAccessToken(expiresInSeconds: number, extra: Record = {}): string { + return makeJwt({ exp: NOW + expiresInSeconds, ...extra }); +} + +/** Reversible fake cipher with a toggleable availability flag. */ +const fakeCipher = { + available: true, + isAvailable() { return this.available; }, + encrypt(plain: string) { return 'enc:' + Buffer.from(plain).toString('base64'); }, + decrypt(encrypted: string) { + if (!encrypted.startsWith('enc:')) throw new Error('bad ciphertext'); + return Buffer.from(encrypted.slice(4), 'base64').toString('utf8'); + }, +}; + +function readStoredFile(): Record { + return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8')) as Record; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +let fetchMock: ReturnType; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_MS); + fakeCipher.available = true; + setTokenCipher(fakeCipher); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + fs.rmSync(AUTH_FILE, { force: true }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +afterAll(() => { + fs.rmSync(tmpWorkDir, { recursive: true, force: true }); +}); + +describe('decodeJwtClaims', () => { + it('decodes the payload with a pure base64url decode', () => { + expect(decodeJwtClaims(makeJwt({ exp: 42, foo: 'bar' }))).toEqual({ exp: 42, foo: 'bar' }); + }); + + it('returns null for malformed input', () => { + expect(decodeJwtClaims('not-a-jwt')).toBeNull(); + expect(decodeJwtClaims('a.!!!.c')).toBeNull(); + expect(decodeJwtClaims(`a.${Buffer.from('[1,2]').toString('base64url')}.c`)).toBeNull(); + }); +}); + +describe('token store', () => { + it('persists tokens encrypted at rest and never writes token values in the clear', async () => { + const accessToken = makeAccessToken(3600); + const identity = await saveChatGPTTokens({ + accessToken, + refreshToken: 'rt_secret_1', + idToken: makeIdToken(), + }); + + expect(identity).toEqual({ accountId: 'acct_123', email: 'user@example.com' }); + + const stored = readStoredFile(); + expect(stored.tokensEncrypted).toMatch(/^enc:/); + expect(stored.tokens).toBeUndefined(); + expect(stored.plaintext).toBeUndefined(); + expect(stored.expiresAt).toBe(NOW + 3600); + // No raw token material anywhere in the file. + const raw = fs.readFileSync(AUTH_FILE, 'utf-8'); + expect(raw).not.toContain(accessToken); + expect(raw).not.toContain('rt_secret_1'); + + await expect(getChatGPTStatus()).resolves.toEqual({ + signedIn: true, + email: 'user@example.com', + accountId: 'acct_123', + }); + }); + + it('falls back to plaintext with a marker when the cipher is unavailable', async () => { + fakeCipher.available = false; + await saveChatGPTTokens({ + accessToken: makeAccessToken(3600), + refreshToken: 'rt_1', + idToken: makeIdToken(), + }); + + const stored = readStoredFile(); + expect(stored.tokensEncrypted).toBeUndefined(); + expect(stored.plaintext).toBe(true); + expect((stored.tokens as { refreshToken: string }).refreshToken).toBe('rt_1'); + + await expect(getChatGPTAccessToken()).resolves.toBe(stored && (stored.tokens as { accessToken: string }).accessToken); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('parses accountId from the access token and email from the profile namespace as fallbacks', async () => { + // No id_token; access token carries the auth namespace + profile email. + await saveChatGPTTokens({ + accessToken: makeAccessToken(3600, { + [CHATGPT_AUTH_CLAIM_NAMESPACE]: { chatgpt_account_id: 'acct_from_at' }, + [CHATGPT_PROFILE_CLAIM_NAMESPACE]: { email: 'profile@example.com' }, + }), + refreshToken: 'rt_1', + }); + + await expect(getChatGPTStatus()).resolves.toEqual({ + signedIn: true, + email: 'profile@example.com', + accountId: 'acct_from_at', + }); + }); + + it('preserves existing identity when a refresh response carries no claims', async () => { + await saveChatGPTTokens({ + accessToken: makeAccessToken(3600), + refreshToken: 'rt_1', + idToken: makeIdToken(), + }); + // Rotated tokens with no identity claims at all. + await saveChatGPTTokens({ + accessToken: makeAccessToken(7200), + refreshToken: 'rt_2', + }); + + await expect(getChatGPTStatus()).resolves.toEqual({ + signedIn: true, + email: 'user@example.com', + accountId: 'acct_123', + }); + }); + + it('reports signed out when nothing is stored', async () => { + await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false }); + await expect(getChatGPTAccessToken()).rejects.toBeInstanceOf(ChatGPTAuthRequiredError); + }); +}); + +describe('getChatGPTAccessToken', () => { + it('returns the cached token without refreshing when >5 min from expiry', async () => { + const accessToken = makeAccessToken(3600); + await saveChatGPTTokens({ accessToken, refreshToken: 'rt_1', idToken: makeIdToken() }); + + await expect(getChatGPTAccessToken()).resolves.toBe(accessToken); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refreshes within the 5-min window using the verified JSON request shape and persists rotation', async () => { + await saveChatGPTTokens({ + accessToken: makeAccessToken(60), // inside the 5-min margin + refreshToken: 'rt_old', + idToken: makeIdToken(), + }); + + const newAccessToken = makeAccessToken(3600); + fetchMock.mockResolvedValueOnce(jsonResponse({ + access_token: newAccessToken, + refresh_token: 'rt_new', + })); + + await expect(getChatGPTAccessToken()).resolves.toBe(newAccessToken); + + // Request shape verified against codex-rs/login/src/auth/manager.rs. + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(CHATGPT_TOKEN_URL); + expect(init.method).toBe('POST'); + expect((init.headers as Record)['Content-Type']).toBe('application/json'); + expect(JSON.parse(init.body as string)).toEqual({ + client_id: CHATGPT_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: 'rt_old', + }); + + // Rotated material persisted (decrypt the file with the fake cipher). + const stored = readStoredFile(); + const material = JSON.parse(fakeCipher.decrypt(stored.tokensEncrypted as string)) as Record; + expect(material.accessToken).toBe(newAccessToken); + expect(material.refreshToken).toBe('rt_new'); + expect(stored.expiresAt).toBe(NOW + 3600); + + // Now fresh — no second refresh. + await expect(getChatGPTAccessToken()).resolves.toBe(newAccessToken); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('keeps the old refresh token when the response omits one', async () => { + await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_keep' }); + fetchMock.mockResolvedValueOnce(jsonResponse({ access_token: makeAccessToken(3600) })); + + await getChatGPTAccessToken(); + + const stored = readStoredFile(); + const material = JSON.parse(fakeCipher.decrypt(stored.tokensEncrypted as string)) as Record; + expect(material.refreshToken).toBe('rt_keep'); + }); + + it('shares a single in-flight refresh across concurrent callers', async () => { + await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_1' }); + + const newAccessToken = makeAccessToken(3600); + let release!: (r: Response) => void; + fetchMock.mockReturnValueOnce(new Promise((resolve) => { release = resolve; })); + + const a = getChatGPTAccessToken(); + const b = getChatGPTAccessToken(); + // Let both callers reach the refresh gate before releasing the response. + await Promise.resolve(); + release(jsonResponse({ access_token: newAccessToken })); + + await expect(a).resolves.toBe(newAccessToken); + await expect(b).resolves.toBe(newAccessToken); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('clears to a clean signed-out state and throws the typed error when the refresh token is rejected', async () => { + await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_revoked' }); + fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'invalid_grant' }, 401)); + + await expect(getChatGPTAccessToken()).rejects.toBeInstanceOf(ChatGPTAuthRequiredError); + expect(fs.existsSync(AUTH_FILE)).toBe(false); + await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false }); + }); + + it('treats 5xx as transient: throws a plain error and keeps the stored tokens', async () => { + await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_1', idToken: makeIdToken() }); + fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'server_error' }, 503)); + + const err = await getChatGPTAccessToken().catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(ChatGPTAuthRequiredError); + await expect(getChatGPTStatus()).resolves.toMatchObject({ signedIn: true }); + }); + + it('treats network failure as transient and keeps the stored tokens', async () => { + await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_1' }); + fetchMock.mockRejectedValueOnce(new Error('fetch failed')); + + const err = await getChatGPTAccessToken().catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(ChatGPTAuthRequiredError); + await expect(getChatGPTStatus()).resolves.toMatchObject({ signedIn: true }); + }); + + it('clears the store and requires sign-in when decryption fails', async () => { + await saveChatGPTTokens({ accessToken: makeAccessToken(3600), refreshToken: 'rt_1' }); + const stored = readStoredFile(); + stored.tokensEncrypted = 'corrupted'; + fs.writeFileSync(AUTH_FILE, JSON.stringify(stored)); + + await expect(getChatGPTAccessToken()).rejects.toBeInstanceOf(ChatGPTAuthRequiredError); + expect(fs.existsSync(AUTH_FILE)).toBe(false); + }); +}); + +describe('signOutChatGPT', () => { + it('best-effort revokes the refresh token (with client_id) then clears the store', async () => { + await saveChatGPTTokens({ + accessToken: makeAccessToken(3600), + refreshToken: 'rt_1', + idToken: makeIdToken(), + }); + fetchMock.mockResolvedValueOnce(jsonResponse({})); + + await signOutChatGPT(); + + // Revoke shape verified against codex-rs/login/src/auth/revoke.rs. + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(CHATGPT_REVOKE_URL); + expect(JSON.parse(init.body as string)).toEqual({ + token: 'rt_1', + token_type_hint: 'refresh_token', + client_id: CHATGPT_CLIENT_ID, + }); + + expect(fs.existsSync(AUTH_FILE)).toBe(false); + await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false }); + }); + + it('still clears the store when revocation fails', async () => { + await saveChatGPTTokens({ accessToken: makeAccessToken(3600), refreshToken: 'rt_1' }); + fetchMock.mockRejectedValueOnce(new Error('offline')); + + await signOutChatGPT(); + + expect(fs.existsSync(AUTH_FILE)).toBe(false); + }); + + it('is a no-op-safe local clear when nothing is stored', async () => { + await signOutChatGPT(); + expect(fetchMock).not.toHaveBeenCalled(); + await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false }); + }); +}); diff --git a/apps/x/packages/core/src/auth/chatgpt-auth.ts b/apps/x/packages/core/src/auth/chatgpt-auth.ts new file mode 100644 index 00000000..0106d976 --- /dev/null +++ b/apps/x/packages/core/src/auth/chatgpt-auth.ts @@ -0,0 +1,389 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { WorkDir } from '../config/config.js'; +import { + CHATGPT_AUTH_CLAIM_NAMESPACE, + CHATGPT_CLIENT_ID, + CHATGPT_PROFILE_CLAIM_NAMESPACE, + CHATGPT_REDIRECT_URI, + CHATGPT_REFRESH_MARGIN_SECONDS, + CHATGPT_REVOKE_URL, + CHATGPT_TOKEN_URL, +} from './chatgpt-constants.js'; + +// "Sign in with ChatGPT" token layer. Owns storage + refresh of the OAuth +// tokens acquired via the Codex CLI client (see chatgpt-constants.ts). The +// interactive sign-in flow (PKCE + loopback server on 127.0.0.1:1455) lives +// in the Electron main process and lands in Phase 2; it persists tokens here +// via saveChatGPTTokens(). Consumers (the Codex Responses model client) must +// go through getChatGPTAccessToken() and never read the store directly. +// +// IMPORTANT: never log token values — log events only. + +const AUTH_FILE = path.join(WorkDir, 'config', 'chatgpt-auth.json'); + +// Token-at-rest encryption is provided by the Electron main process +// (safeStorage) — core stays electron-free. When no cipher is wired (or the +// OS keychain is unavailable) tokens are stored plaintext with a marker, +// matching the existing GitHub token storage in apps/github-auth.ts. +export interface TokenCipher { + isAvailable(): boolean; + encrypt(plain: string): string; // returns base64 + decrypt(encrypted: string): string; +} +let cipher: TokenCipher | null = null; +export function setTokenCipher(c: TokenCipher): void { + cipher = c; +} + +/** The sensitive material — stored encrypted when the cipher is available. */ +type TokenMaterial = { + accessToken: string; + refreshToken: string; +}; + +type StoredChatGPTAuth = { + /** ChatGPT account id, parsed from the id_token (see extractIdentity). */ + accountId?: string; + email?: string; + /** Unix seconds — the access token's `exp` claim. */ + expiresAt: number; + createdAt: string; + /** base64 ciphertext of JSON TokenMaterial, via the injected cipher. */ + tokensEncrypted?: string; + /** Plaintext fallback when no cipher/keychain is available. */ + tokens?: TokenMaterial; + plaintext?: boolean; +}; + +/** + * Thrown when there is no usable ChatGPT session — never signed in, refresh + * token revoked/expired, or the stored tokens are unreadable. Callers should + * surface "Sign in with ChatGPT" and must not retry. + */ +export class ChatGPTAuthRequiredError extends Error { + constructor(message = 'ChatGPT sign-in required') { + super(message); + this.name = 'ChatGPTAuthRequiredError'; + } +} + +/** + * Decode a JWT's payload claims with a pure base64url decode — no signature + * verification. Fine for our use: we only mine identity/expiry hints from + * tokens we received directly from the token endpoint over TLS. + */ +export function decodeJwtClaims(jwt: string): Record | null { + const parts = jwt.split('.'); + if (parts.length < 2 || !parts[1]) return null; + try { + const payload = Buffer.from(parts[1], 'base64url').toString('utf8'); + const parsed: unknown = JSON.parse(payload); + return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : null; + } catch { + return null; + } +} + +function claimString(source: unknown, key: string): string | undefined { + if (source !== null && typeof source === 'object') { + const value = (source as Record)[key]; + if (typeof value === 'string' && value.length > 0) return value; + } + return undefined; +} + +/** + * Identity claims, per codex-rs/login/src/token_data.rs: + * - accountId: `chatgpt_account_id` inside "https://api.openai.com/auth" + * - email: root `email` claim, falling back to `email` inside + * "https://api.openai.com/profile" + * We check the id_token first (codex parses identity from it), then the + * access token, which carries the same auth claim namespace. + */ +function extractIdentity(tokens: Array): { accountId?: string; email?: string } { + let accountId: string | undefined; + let email: string | undefined; + for (const token of tokens) { + if (!token) continue; + const claims = decodeJwtClaims(token); + if (!claims) continue; + accountId ??= claimString(claims[CHATGPT_AUTH_CLAIM_NAMESPACE], 'chatgpt_account_id'); + email ??= claimString(claims, 'email') + ?? claimString(claims[CHATGPT_PROFILE_CLAIM_NAMESPACE], 'email'); + } + return { accountId, email }; +} + +async function readAuth(): Promise { + try { + return JSON.parse(await fs.readFile(AUTH_FILE, 'utf-8')) as StoredChatGPTAuth; + } catch { + return null; + } +} + +async function writeAuth(auth: StoredChatGPTAuth): Promise { + await fs.mkdir(path.dirname(AUTH_FILE), { recursive: true }); + await fs.writeFile(AUTH_FILE, JSON.stringify(auth, null, 2), { mode: 0o600 }); +} + +async function clearStore(): Promise { + await fs.rm(AUTH_FILE, { force: true }); +} + +/** + * Read the sensitive token material from a stored entry. Returns null when it + * cannot be read; a failed DECRYPT additionally clears the store (keychain + * changed / corrupt ciphertext is unrecoverable — force a clean re-sign-in, + * mirroring the GitHub token path). + */ +async function getTokenMaterial(auth: StoredChatGPTAuth): Promise { + if (auth.tokensEncrypted && cipher?.isAvailable()) { + try { + const material = JSON.parse(cipher.decrypt(auth.tokensEncrypted)) as TokenMaterial; + if (typeof material.accessToken === 'string' && typeof material.refreshToken === 'string') { + return material; + } + throw new Error('malformed token material'); + } catch { + console.warn('[ChatGPTAuth] Failed to decrypt stored tokens; clearing auth'); + await clearStore(); + return null; + } + } + if (auth.tokens) return auth.tokens; + if (auth.tokensEncrypted) { + // Encrypted store but no cipher wired (e.g. keychain unavailable this + // launch). Don't clear — the tokens may become readable again. + console.warn('[ChatGPTAuth] Stored tokens are encrypted but no cipher is available'); + } + return null; +} + +/** + * Persist tokens (called by the Phase 2 sign-in flow and by refresh). + * Derives expiry from the access token's `exp` claim and identity from the + * id_token / access token claims; existing identity is preserved when a + * refresh response doesn't carry the claims. + */ +export async function saveChatGPTTokens(input: { + accessToken: string; + refreshToken: string; + idToken?: string; +}): Promise<{ accountId?: string; email?: string }> { + const existing = await readAuth(); + + const identity = extractIdentity([input.idToken, input.accessToken]); + const accountId = identity.accountId ?? existing?.accountId; + const email = identity.email ?? existing?.email; + + const exp = decodeJwtClaims(input.accessToken)?.exp; + let expiresAt: number; + if (typeof exp === 'number') { + expiresAt = exp; + } else { + // The token endpoint returns no expires_in (verified against + // codex-rs/login/src/auth/manager.rs) — the JWT exp claim is the only + // expiry source. Without it, assume a conservative 1h lifetime. + console.warn('[ChatGPTAuth] Access token has no exp claim; assuming 1h lifetime'); + expiresAt = Math.floor(Date.now() / 1000) + 3600; + } + + const auth: StoredChatGPTAuth = { + ...(accountId ? { accountId } : {}), + ...(email ? { email } : {}), + expiresAt, + createdAt: existing?.createdAt ?? new Date().toISOString(), + }; + const material: TokenMaterial = { + accessToken: input.accessToken, + refreshToken: input.refreshToken, + }; + if (cipher?.isAvailable()) { + auth.tokensEncrypted = cipher.encrypt(JSON.stringify(material)); + } else { + auth.tokens = material; + auth.plaintext = true; + } + await writeAuth(auth); + return { accountId, email }; +} + +/** + * Exchange an authorization code for tokens and persist them (called by the + * main process sign-in flow after the loopback callback). Request/response + * shape verified against codex-rs/login/src/server.rs + * (`exchange_code_for_tokens`): form-encoded POST — unlike the JSON refresh — + * response `{ id_token, access_token, refresh_token }`, no expires_in. + * Returns the parsed identity for the caller to surface. + */ +export async function exchangeChatGPTCode( + code: string, + codeVerifier: string, +): Promise<{ accountId?: string; email?: string }> { + const res = await fetch(CHATGPT_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: CHATGPT_REDIRECT_URI, + client_id: CHATGPT_CLIENT_ID, + code_verifier: codeVerifier, + }).toString(), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + throw new Error(`ChatGPT token exchange failed: HTTP ${res.status}`); + } + const body = await res.json() as { + id_token?: string; + access_token?: string; + refresh_token?: string; + }; + if (!body.access_token || !body.refresh_token) { + throw new Error('ChatGPT token exchange response is missing tokens'); + } + const identity = await saveChatGPTTokens({ + accessToken: body.access_token, + refreshToken: body.refresh_token, + ...(body.id_token ? { idToken: body.id_token } : {}), + }); + console.log('[ChatGPTAuth] Sign-in token exchange complete'); + return identity; +} + +// Single-flight refresh: concurrent expired-token callers share one request +// (same pattern as the Rowboat gateway token in auth/tokens.ts). One refresh +// owner matters here — parallel refreshes racing on one refresh_token can +// invalidate each other's grant. +let refreshInFlight: Promise | null = null; + +async function performRefresh(refreshToken: string): Promise { + // Request/response shape verified against codex-rs/login/src/auth/manager.rs: + // JSON body (not form-encoded), response may omit refresh_token (keep the + // old one) and never carries expires_in. + let res: Response; + try { + res = await fetch(CHATGPT_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: CHATGPT_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + signal: AbortSignal.timeout(30_000), + }); + } catch (error) { + // Network failure: transient — keep the stored tokens so the next + // call retries instead of forcing a re-sign-in. + const message = error instanceof Error ? error.message : String(error); + throw new Error(`ChatGPT token refresh failed: ${message}`); + } + + if (res.status === 400 || res.status === 401) { + // Refresh token revoked or expired — unrecoverable without the user. + console.log(`[ChatGPTAuth] Refresh rejected (HTTP ${res.status}); signing out`); + await clearStore(); + throw new ChatGPTAuthRequiredError('ChatGPT session expired. Please sign in again.'); + } + if (!res.ok) { + // 5xx / rate limit: transient — keep the stored tokens. + throw new Error(`ChatGPT token refresh failed: HTTP ${res.status}`); + } + + const body = await res.json() as { + id_token?: string; + access_token?: string; + refresh_token?: string; + }; + if (!body.access_token) { + throw new Error('ChatGPT token refresh returned no access token'); + } + + await saveChatGPTTokens({ + accessToken: body.access_token, + refreshToken: body.refresh_token || refreshToken, + ...(body.id_token ? { idToken: body.id_token } : {}), + }); + console.log('[ChatGPTAuth] Access token refreshed'); + return body.access_token; +} + +/** + * The one seam for consumers (the Codex Responses model client): returns a + * valid access token, transparently refreshing when within 5 minutes of + * expiry. Throws ChatGPTAuthRequiredError when there is no usable session. + */ +export async function getChatGPTAccessToken(): Promise { + const auth = await readAuth(); + if (!auth) { + throw new ChatGPTAuthRequiredError(); + } + const material = await getTokenMaterial(auth); + if (!material) { + throw new ChatGPTAuthRequiredError(); + } + + const now = Math.floor(Date.now() / 1000); + if (auth.expiresAt - now > CHATGPT_REFRESH_MARGIN_SECONDS) { + return material.accessToken; + } + + if (!refreshInFlight) { + refreshInFlight = performRefresh(material.refreshToken).finally(() => { + refreshInFlight = null; + }); + } + return refreshInFlight; +} + +/** Connection state for the UI. Never returns token values. */ +export async function getChatGPTStatus(): Promise<{ signedIn: boolean; email?: string; accountId?: string }> { + const auth = await readAuth(); + if (!auth || (!auth.tokensEncrypted && !auth.tokens)) { + return { signedIn: false }; + } + return { + signedIn: true, + ...(auth.email ? { email: auth.email } : {}), + ...(auth.accountId ? { accountId: auth.accountId } : {}), + }; +} + +/** + * Sign out: best-effort revocation at auth.openai.com (endpoint + request + * shape verified in codex-rs/login/src/auth/revoke.rs — refresh token first, + * with client_id; access token as fallback, without), then clear the local + * store. Revocation failure never blocks the local sign-out. + */ +export async function signOutChatGPT(): Promise { + const auth = await readAuth(); + if (auth) { + const material = await getTokenMaterial(auth); + if (material) { + const body = material.refreshToken + ? { token: material.refreshToken, token_type_hint: 'refresh_token', client_id: CHATGPT_CLIENT_ID } + : { token: material.accessToken, token_type_hint: 'access_token' }; + try { + const res = await fetch(CHATGPT_REVOKE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + console.warn(`[ChatGPTAuth] Token revocation returned HTTP ${res.status}; continuing with local sign-out`); + } + } catch { + console.warn('[ChatGPTAuth] Token revocation failed; continuing with local sign-out'); + } + } + } + await clearStore(); + console.log('[ChatGPTAuth] Signed out'); +} diff --git a/apps/x/packages/core/src/auth/chatgpt-constants.ts b/apps/x/packages/core/src/auth/chatgpt-constants.ts new file mode 100644 index 00000000..6e64c058 --- /dev/null +++ b/apps/x/packages/core/src/auth/chatgpt-constants.ts @@ -0,0 +1,97 @@ +// OAuth constants for "Sign in with ChatGPT" (ChatGPT subscription auth). +// +// Rowboat authenticates against OpenAI's auth server using the SAME public +// client the open-source Codex CLI uses. Every value below was verified +// against the openai/codex sources on 2026-07-15 — do not edit from memory; +// re-check the linked files instead. +// +// Sources (github.com/openai/codex, main branch): +// - codex-rs/login/src/auth/manager.rs → CLIENT_ID, refresh request shape, +// 5-minute access-token refresh window +// - codex-rs/login/src/server.rs → DEFAULT_ISSUER, /oauth/authorize, +// /oauth/token, DEFAULT_PORT 1455, /auth/callback, scopes, extra +// authorize params +// - codex-rs/login/src/auth/revoke.rs → /oauth/revoke request shape +// - codex-rs/login/src/token_data.rs → JWT claim names for account id +// and email + +/** + * OAuth client id of the official Codex CLI. + * Source: codex-rs/login/src/auth/manager.rs (`pub const CLIENT_ID`). + * Client ids are public identifiers, not secrets. + */ +export const CHATGPT_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; + +/** Source: codex-rs/login/src/server.rs (`DEFAULT_ISSUER`). */ +export const CHATGPT_ISSUER = 'https://auth.openai.com'; + +/** Source: codex-rs/login/src/server.rs (`build_authorize_url`: `{issuer}/oauth/authorize`). */ +export const CHATGPT_AUTHORIZE_URL = `${CHATGPT_ISSUER}/oauth/authorize`; + +/** + * Token endpoint, used for both code exchange and refresh. + * Source: codex-rs/login/src/server.rs (`exchange_code_for_tokens`) and + * codex-rs/login/src/auth/manager.rs (refresh POSTs here). + * + * NOTE (verified in manager.rs): the refresh request is a JSON body — + * `{ client_id, grant_type: "refresh_token", refresh_token }` with + * Content-Type application/json, NOT the form-encoded body standard OAuth + * clients send. The response is `{ id_token?, access_token?, refresh_token? }` + * and carries NO `expires_in` — expiry must be read from the new access + * token's `exp` claim. + */ +export const CHATGPT_TOKEN_URL = `${CHATGPT_ISSUER}/oauth/token`; + +/** + * Revocation endpoint. Source: codex-rs/login/src/auth/revoke.rs — JSON POST + * `{ token, token_type_hint: "refresh_token"|"access_token", client_id }` + * (client_id only when revoking a refresh token), 10s timeout. + */ +export const CHATGPT_REVOKE_URL = `${CHATGPT_ISSUER}/oauth/revoke`; + +/** + * The loopback callback the Codex client id is registered for. The port is + * fixed — unlike Rowboat's DCR providers there is no scan-to-next-port + * fallback, because the redirect URI is pre-registered at OpenAI. + * Source: codex-rs/login/src/server.rs (`DEFAULT_PORT: u16 = 1455`, + * `http://localhost:{port}/auth/callback`). + */ +export const CHATGPT_CALLBACK_PORT = 1455; +export const CHATGPT_CALLBACK_PATH = '/auth/callback'; +export const CHATGPT_REDIRECT_URI = `http://localhost:${CHATGPT_CALLBACK_PORT}${CHATGPT_CALLBACK_PATH}`; + +/** + * Scopes to request at authorize time (Phase 2). + * Source: codex-rs/login/src/server.rs requests + * "openid profile email offline_access api.connectors.read api.connectors.invoke"; + * we deliberately drop the two `api.connectors.*` scopes — Rowboat only needs + * identity + refresh (`offline_access`) for model calls, matching what Zed's + * ChatGPT provider requests. + */ +export const CHATGPT_SCOPES = ['openid', 'profile', 'email', 'offline_access']; + +/** + * Extra authorize-URL query params the Codex flow sends (Phase 2). + * Source: codex-rs/login/src/server.rs (`build_authorize_url`). + */ +export const CHATGPT_EXTRA_AUTHORIZE_PARAMS: Record = { + id_token_add_organizations: 'true', + codex_cli_simplified_flow: 'true', +}; + +/** + * Refresh the access token when it is within this margin of expiry. + * Source: codex-rs/login/src/auth/manager.rs + * (`CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES: i64 = 5`). + */ +export const CHATGPT_REFRESH_MARGIN_SECONDS = 5 * 60; + +/** + * JWT claim namespaces. Source: codex-rs/login/src/token_data.rs — the + * ChatGPT account id is `chatgpt_account_id` inside the + * "https://api.openai.com/auth" claim of the id_token; email is the root + * `email` claim with a fallback to `email` inside + * "https://api.openai.com/profile". + */ +export const CHATGPT_AUTH_CLAIM_NAMESPACE = 'https://api.openai.com/auth'; +export const CHATGPT_PROFILE_CLAIM_NAMESPACE = 'https://api.openai.com/profile'; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 98191887..fc29e83b 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -783,6 +783,44 @@ const ipcSchemas = { }), res: z.null(), }, + // --- "Sign in with ChatGPT" (subscription OAuth via the Codex CLI client) --- + // Raw tokens are never exposed over IPC — the renderer only sees identity + // and connection state. + 'chatgpt:getStatus': { + req: z.null(), + res: z.object({ + signedIn: z.boolean(), + email: z.string().optional(), + accountId: z.string().optional(), + }), + }, + // Resolves when the browser flow settles (success, denial, timeout, port + // busy, exchange failure, cancellation) — same shape as getStatus plus an + // error string; `cancelled` marks expected teardown (no error toast). + 'chatgpt:signIn': { + req: z.null(), + res: z.object({ + signedIn: z.boolean(), + email: z.string().optional(), + accountId: z.string().optional(), + cancelled: z.boolean().optional(), + error: z.string().optional(), + }), + }, + // Abort the pending sign-in attempt: stops the loopback server and settles + // the in-flight chatgpt:signIn with a cancelled outcome. No-op when idle. + 'chatgpt:cancelSignIn': { + req: z.null(), + res: z.object({ + success: z.boolean(), + }), + }, + 'chatgpt:signOut': { + req: z.null(), + res: z.object({ + success: z.boolean(), + }), + }, 'app:openUrl': { req: z.object({ url: z.string(), From 6c5324eeac9cac2f5274b9985268476ce38e2c6e Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:21:29 +0530 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20ChatGPT=20subscription=20(codex)=20?= =?UTF-8?q?models=20=E2=80=94=20provider,=20model=20list,=20composer=20wir?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the model-call half of "Use your ChatGPT subscription": a new credential-less "codex" provider flavor (like "rowboat") that runs calls against the Codex backend using the OAuth session from chatgpt-auth. - core/models/codex.ts: AI SDK OpenAI provider in Responses mode. A wrapLanguageModel middleware injects providerOptions.openai.store=false on every call so the SDK emits full self-contained items (text + reasoning with encrypted_content) instead of item_reference entries the stateless backend 404s. A wire-level fetch wrapper adds auth (bearer + chatgpt-account-id) and Cloudflare headers (originator), and backstops the contract: store:false, stream forced with SSE aggregation for non-streaming callers, max_output_tokens dropped, encrypted- reasoning include, friendly usage-limit errors - live model discovery via /codex/models, gated by client_version (mirrored from the local codex CLI's models_cache.json so the catalog matches what `codex` shows); visibility "hide" utility models filtered; display names carried through; hardcoded fallback list for offline - createProvider/resolveProviderConfig/mapReasoningEffort codex cases - models:list merges the "OpenAI Codex" catalog while signed in with ChatGPT; chatgpt:statusChanged push event on sign-in/out - composer picker shows the group gated on the session and refreshes on status changes Co-Authored-By: Claude Fable 5 --- apps/x/apps/main/src/ipc.ts | 27 +- .../components/chat-input-with-mentions.tsx | 17 +- apps/x/packages/core/src/models/codex.test.ts | 165 ++++++++++ apps/x/packages/core/src/models/codex.ts | 303 ++++++++++++++++++ apps/x/packages/core/src/models/defaults.ts | 5 + apps/x/packages/core/src/models/models.ts | 3 + .../core/src/models/reasoning.test.ts | 7 + apps/x/packages/core/src/models/reasoning.ts | 7 + apps/x/packages/shared/src/ipc.ts | 9 + apps/x/packages/shared/src/models.ts | 5 +- 10 files changed, 542 insertions(+), 6 deletions(-) create mode 100644 apps/x/packages/core/src/models/codex.test.ts create mode 100644 apps/x/packages/core/src/models/codex.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 3759a6a5..8d0c8879 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -41,6 +41,7 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { getChatGPTStatus, signOutChatGPT } from '@x/core/dist/auth/chatgpt-auth.js'; +import { listCodexModels } from '@x/core/dist/models/codex.js'; import { signInWithChatGPT, cancelChatGPTSignIn } from './chatgpt-signin.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js'; @@ -1234,10 +1235,21 @@ export function setupIpcHandlers() { } }, 'models:list': async () => { - if (await isSignedIn()) { - return await listGatewayModels(); + const base = (await isSignedIn()) + ? await listGatewayModels() + : await listOnboardingModels(); + // ChatGPT-subscription (codex) models are additive and independent of + // Rowboat sign-in; their failure must never break the main list. + try { + const chatgpt = await getChatGPTStatus(); + if (chatgpt.signedIn) { + const codex = await listCodexModels(); + return { providers: [...base.providers, ...codex.providers] }; + } + } catch (error) { + console.warn('[Codex] Listing subscription models failed:', error); } - return await listOnboardingModels(); + return base; }, 'models:test': async (_event, args) => { return await testModelConnection(args.provider, args.model); @@ -1291,7 +1303,13 @@ export function setupIpcHandlers() { return await getChatGPTStatus(); }, 'chatgpt:signIn': async () => { - return await signInWithChatGPT(); + const result = await signInWithChatGPT(); + if (result.signedIn) { + // Model lists gate on sign-in state (composer picker, models:list) — + // push the change so they refresh without polling. + broadcastToWindows('chatgpt:statusChanged', { signedIn: true }); + } + return result; }, 'chatgpt:cancelSignIn': async () => { await cancelChatGPTSignIn(); @@ -1300,6 +1318,7 @@ export function setupIpcHandlers() { 'chatgpt:signOut': async () => { try { await signOutChatGPT(); + broadcastToWindows('chatgpt:statusChanged', { signedIn: false }); return { success: true }; } catch (error) { console.error('[ChatGPTAuth] Sign-out failed:', error); diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 9e6d76b4..be1bba98 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -94,9 +94,12 @@ const providerDisplayNames: Record = { aigateway: 'AI Gateway', 'openai-compatible': 'OpenAI-Compatible', rowboat: 'Rowboat', + // Matches what other subscription clients call this provider; the auth + // itself is "Sign in with ChatGPT" (Plus/Pro subscription). + codex: 'OpenAI Codex', } -type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" +type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" | "codex" interface ConfiguredModel { provider: ProviderName @@ -540,6 +543,12 @@ function ChatInputInner({ groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] }) } + // ChatGPT subscription (codex): models:list only carries this catalog + // while signed in with ChatGPT, so presence is the gate. + if ((catalog['codex'] || []).length > 0) { + groups.push({ kind: 'catalog', flavor: 'codex', models: catalog['codex'] }) + } + try { const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) const parsed = JSON.parse(result.data) @@ -615,6 +624,12 @@ function ChatInputInner({ loadModelConfig() }, [isActive, loadModelConfig]) + // ChatGPT subscription models appear/disappear with the ChatGPT session. + useEffect(() => { + const cleanup = window.ipc.on('chatgpt:statusChanged', () => { loadModelConfig() }) + return cleanup + }, [loadModelConfig]) + // Reload when model config changes (e.g. from settings dialog) useEffect(() => { const handler = () => { loadModelConfig() } diff --git a/apps/x/packages/core/src/models/codex.test.ts b/apps/x/packages/core/src/models/codex.test.ts new file mode 100644 index 00000000..8fe0e70a --- /dev/null +++ b/apps/x/packages/core/src/models/codex.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + normalizeRequestBody, + aggregateSseResponse, + listCodexModels, + codexStoreMiddleware, + CODEX_BASE_URL, +} from './codex.js'; +import type { LanguageModelV4, LanguageModelV4CallOptions } from '@ai-sdk/provider'; + +vi.mock('../auth/chatgpt-auth.js', () => ({ + getChatGPTAccessToken: vi.fn(async () => 'test-access-token'), + getChatGPTStatus: vi.fn(async () => ({ signedIn: true, accountId: 'acct-123' })), +})); + +describe('normalizeRequestBody', () => { + it('enforces the Codex wire contract on a bare request', () => { + const { body, forcedStream } = normalizeRequestBody(JSON.stringify({ + model: 'gpt-5.5', + input: [], + store: true, + max_output_tokens: 4096, + })); + const parsed = JSON.parse(body); + expect(parsed.store).toBe(false); + expect(parsed).not.toHaveProperty('max_output_tokens'); + expect(parsed.instructions).toBe('You are a helpful assistant.'); + expect(parsed.include).toContain('reasoning.encrypted_content'); + expect(parsed.reasoning).toEqual({ summary: 'auto' }); + expect(parsed.stream).toBe(true); + expect(forcedStream).toBe(true); + }); + + it('preserves caller instructions, reasoning fields, and existing includes', () => { + const { body, forcedStream } = normalizeRequestBody(JSON.stringify({ + instructions: 'Be terse.', + include: ['message.output_text.logprobs'], + reasoning: { effort: 'high', summary: 'detailed' }, + stream: true, + })); + const parsed = JSON.parse(body); + expect(parsed.instructions).toBe('Be terse.'); + expect(parsed.include).toEqual( + expect.arrayContaining(['message.output_text.logprobs', 'reasoning.encrypted_content']), + ); + expect(parsed.reasoning).toEqual({ effort: 'high', summary: 'detailed' }); + expect(forcedStream).toBe(false); + }); + + it('passes non-JSON bodies through untouched', () => { + expect(normalizeRequestBody('not json')).toEqual({ body: 'not json', forcedStream: false }); + }); +}); + +function sseResponse(events: string[]): Response { + const body = events.map((e) => `data: ${e}`).join('\n\n') + '\n\n'; + return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } }); +} + +describe('aggregateSseResponse', () => { + it('returns the response.completed payload as plain JSON', async () => { + const res = await aggregateSseResponse(sseResponse([ + JSON.stringify({ type: 'response.created' }), + JSON.stringify({ type: 'response.completed', response: { id: 'resp_1', output: [] } }), + ])); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('application/json'); + expect(await res.json()).toEqual({ id: 'resp_1', output: [] }); + }); + + it('surfaces response.failed as an error response', async () => { + const res = await aggregateSseResponse(sseResponse([ + JSON.stringify({ type: 'response.failed', response: { error: { message: 'quota exceeded' } } }), + ])); + expect(res.status).toBe(502); + const body = await res.json() as { error: { message: string } }; + expect(body.error.message).toBe('quota exceeded'); + }); + + it('errors when the stream ends without a terminal event', async () => { + const res = await aggregateSseResponse(sseResponse([ + JSON.stringify({ type: 'response.created' }), + ])); + expect(res.status).toBe(502); + }); +}); + +describe('codexStoreMiddleware', () => { + it('injects providerOptions.openai.store=false so the SDK never emits item references', async () => { + const params = { + prompt: [], + providerOptions: { openai: { reasoningEffort: 'high' } }, + } as unknown as LanguageModelV4CallOptions; + const out = await codexStoreMiddleware.transformParams!({ + type: 'stream', + params, + model: {} as LanguageModelV4, + }); + expect(out.providerOptions).toEqual({ + openai: { reasoningEffort: 'high', store: false }, + }); + }); + + it('overrides a caller-supplied store=true', async () => { + const params = { + prompt: [], + providerOptions: { openai: { store: true } }, + } as unknown as LanguageModelV4CallOptions; + const out = await codexStoreMiddleware.transformParams!({ + type: 'generate', + params, + model: {} as LanguageModelV4, + }); + expect(out.providerOptions?.openai?.store).toBe(false); + }); +}); + +describe('listCodexModels', () => { + const realFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = realFetch; + vi.restoreAllMocks(); + }); + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('discovers models with auth + originator headers, filtering hidden and sorting by priority', async () => { + let seenHeaders: Headers | undefined; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toContain(`${CODEX_BASE_URL}/models?client_version=`); + seenHeaders = new Headers(init?.headers); + return new Response(JSON.stringify({ + models: [ + { slug: 'gpt-5.4', priority: 2, visibility: 'list' }, + { slug: 'codex-auto-review', priority: 0, visibility: 'hide' }, + { slug: 'gpt-5.5', priority: 1, visibility: 'list', display_name: 'GPT-5.5' }, + ], + }), { status: 200 }); + }) as typeof fetch; + + const result = await listCodexModels(); + expect(result.providers).toHaveLength(1); + expect(result.providers[0]?.id).toBe('codex'); + expect(result.providers[0]?.name).toBe('OpenAI Codex'); + expect(result.providers[0]?.models).toEqual([ + { id: 'gpt-5.5', name: 'GPT-5.5', reasoning: true }, + { id: 'gpt-5.4', reasoning: true }, + ]); + expect(seenHeaders?.get('Authorization')).toBe('Bearer test-access-token'); + expect(seenHeaders?.get('chatgpt-account-id')).toBe('acct-123'); + expect(seenHeaders?.get('originator')).toBe('codex_cli_rs'); + }); + + it('falls back to the hardcoded list when discovery fails', async () => { + globalThis.fetch = vi.fn(async () => { + throw new Error('offline'); + }) as typeof fetch; + + const result = await listCodexModels(); + const ids = result.providers[0]?.models.map((m) => m.id); + expect(ids).toContain('gpt-5.6-sol'); + expect(result.providers[0]?.models.every((m) => m.reasoning === true)).toBe(true); + }); +}); diff --git a/apps/x/packages/core/src/models/codex.ts b/apps/x/packages/core/src/models/codex.ts new file mode 100644 index 00000000..f14fec1f --- /dev/null +++ b/apps/x/packages/core/src/models/codex.ts @@ -0,0 +1,303 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { ProviderV4, LanguageModelV4Middleware } from '@ai-sdk/provider'; +import { createOpenAI } from '@ai-sdk/openai'; +import { wrapLanguageModel } from 'ai'; +import { getChatGPTAccessToken, getChatGPTStatus } from '../auth/chatgpt-auth.js'; + +// "ChatGPT subscription" model provider (flavor "codex"): runs model calls +// against the Codex backend that powers the Codex CLI, authorized by the +// "Sign in with ChatGPT" OAuth session (auth/chatgpt-auth.ts) instead of an +// API key. Like the "rowboat" gateway flavor, it has no models.json entry — +// resolveProviderConfig returns a bare { flavor: "codex" } and auth is +// injected per request here. +// +// The backend speaks ONLY the OpenAI Responses API, with quirks that every +// third-party client (Codex CLI itself, Zed, others) handles the same way — +// see codexFetch. Wire shape cross-checked against the open-source Codex CLI +// and known-working third-party integrations on 2026-07-17. + +export const CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'; + +// Cloudflare in front of chatgpt.com challenges requests whose `originator` +// isn't a known first-party Codex client, regardless of valid auth — so we +// identify as codex_cli_rs, the same approach other subscription clients +// ship. +const CODEX_ORIGINATOR = 'codex_cli_rs'; + +// The backend gates the model catalog by client_version — a version that +// predates a model family never sees it (e.g. 0.142.x gets no gpt-5.6-*). +// When the codex CLI is installed locally, mirror the version its own cache +// records so our catalog matches what `codex` shows in the terminal; +// otherwise fall back to a pinned recent release. +const CODEX_DEFAULT_CLIENT_VERSION = '0.144.5'; +let clientVersionPromise: Promise | null = null; +function codexClientVersion(): Promise { + clientVersionPromise ??= (async () => { + try { + const cachePath = path.join(os.homedir(), '.codex', 'models_cache.json'); + const parsed = JSON.parse(await fs.readFile(cachePath, 'utf-8')) as { client_version?: string }; + if (typeof parsed.client_version === 'string' && /^\d+\.\d+\.\d+$/.test(parsed.client_version)) { + return parsed.client_version; + } + } catch { /* no local codex CLI install */ } + return CODEX_DEFAULT_CLIENT_VERSION; + })(); + return clientVersionPromise; +} + +// Used when live discovery (listCodexModels) is unreachable. Discovery wins +// outright when it returns anything: the backend rejects retired slugs with +// HTTP 400, so appending stale hardcoded ids to a live list only creates +// broken picker rows. +const CODEX_FALLBACK_MODELS = [ + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', +]; + +/** + * Wire-level normalization for the Codex backend. Applied via fetch (not + * providerOptions) so EVERY caller — chat turns, generateText one-shots, + * generateObject classifiers, connection tests — gets a valid request + * without knowing codex specifics: + * + * - `store: false` is mandatory (the backend rejects store:true). + * - `stream: true` is mandatory (stream:false is rejected outright); + * non-streaming callers get their SSE aggregated back into a plain JSON + * response below. + * - `max_output_tokens` is rejected ("Unsupported parameter") — dropped. + * - `instructions` must be non-empty. + * - `include: ["reasoning.encrypted_content"]` keeps reasoning usable + * across tool-call steps despite store:false (the AI SDK relays the + * encrypted blobs back on subsequent requests). + * - a reasoning summary is requested by default so the UI has visible + * reasoning even when the user picked no explicit effort. + */ +export function normalizeRequestBody(raw: string): { body: string; forcedStream: boolean } { + let parsed: Record; + try { + const value: unknown = JSON.parse(raw); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { body: raw, forcedStream: false }; + } + parsed = value as Record; + } catch { + return { body: raw, forcedStream: false }; + } + + parsed.store = false; + delete parsed.max_output_tokens; + if (typeof parsed.instructions !== 'string' || parsed.instructions.length === 0) { + parsed.instructions = 'You are a helpful assistant.'; + } + const include = new Set(Array.isArray(parsed.include) ? parsed.include as unknown[] : []); + include.add('reasoning.encrypted_content'); + parsed.include = [...include]; + const reasoning = (parsed.reasoning !== null && typeof parsed.reasoning === 'object') + ? parsed.reasoning as Record + : {}; + reasoning.summary ??= 'auto'; + parsed.reasoning = reasoning; + + let forcedStream = false; + if (parsed.stream !== true) { + parsed.stream = true; + forcedStream = true; + } + return { body: JSON.stringify(parsed), forcedStream }; +} + +/** + * Collapse a forced SSE stream back into the plain JSON response the + * non-streaming caller expects: the terminal `response.completed` event + * carries the full response object. + */ +export async function aggregateSseResponse(res: Response): Promise { + const text = await res.text(); + let completed: unknown; + let failure: string | undefined; + for (const line of text.split('\n')) { + if (!line.startsWith('data:')) continue; + const payload = line.slice(5).trim(); + if (!payload || payload === '[DONE]') continue; + let event: { type?: string; response?: unknown; message?: string }; + try { + event = JSON.parse(payload) as typeof event; + } catch { + continue; + } + if (event.type === 'response.completed') { + completed = event.response; + } else if (event.type === 'response.failed') { + const error = (event.response as { error?: { message?: string } } | undefined)?.error; + failure = error?.message ?? 'Codex request failed'; + } else if (event.type === 'error') { + failure = event.message ?? 'Codex request failed'; + } + } + const headers = new Headers(res.headers); + headers.set('content-type', 'application/json'); + headers.delete('content-length'); + headers.delete('content-encoding'); + if (completed !== undefined) { + return new Response(JSON.stringify(completed), { status: 200, headers }); + } + return new Response( + JSON.stringify({ error: { message: failure ?? 'Codex stream ended without a completed response' } }), + { status: 502, headers }, + ); +} + +/** + * Rewrite subscription-limit errors into something a user can act on. The + * backend's 429 carries `plan_type` / `resets_at` (unix seconds) on the + * error object; the raw message is unhelpful. Everything else passes + * through untouched (body re-wrapped since reading consumed it). + */ +async function normalizeErrorResponse(res: Response): Promise { + const raw = await res.text(); + const headers = new Headers(res.headers); + headers.delete('content-length'); + headers.delete('content-encoding'); + let body = raw; + if (res.status === 429) { + try { + const parsed = JSON.parse(raw) as { + error?: { message?: string; plan_type?: string; resets_at?: number }; + }; + if (parsed.error) { + const plan = parsed.error.plan_type ? ` (${parsed.error.plan_type} plan)` : ''; + let when = ''; + if (typeof parsed.error.resets_at === 'number') { + const minutes = Math.max(1, Math.ceil((parsed.error.resets_at * 1000 - Date.now()) / 60_000)); + when = minutes >= 120 ? ` Try again in ~${Math.round(minutes / 60)}h.` : ` Try again in ~${minutes} min.`; + } + parsed.error.message = `You have hit your ChatGPT subscription usage limit${plan}.${when}`; + body = JSON.stringify(parsed); + } + } catch { /* keep the original body */ } + } + return new Response(body, { status: res.status, statusText: res.statusText, headers }); +} + +// Auth + identity + Cloudflare headers on every request; body normalization +// on Responses calls. Throws ChatGPTAuthRequiredError when signed out — +// callers surface "Sign in with ChatGPT". +const codexFetch: typeof fetch = async (input, init) => { + const token = await getChatGPTAccessToken(); + const { accountId } = await getChatGPTStatus(); + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${token}`); + if (accountId) headers.set('chatgpt-account-id', accountId); + headers.set('originator', CODEX_ORIGINATOR); + headers.set('User-Agent', `${CODEX_ORIGINATOR}/${await codexClientVersion()} (Rowboat)`); + + let body = init?.body; + let forcedStream = false; + if (typeof body === 'string') { + ({ body, forcedStream } = normalizeRequestBody(body)); + } + + const res = await fetch(input, { ...init, headers, ...(body === undefined ? {} : { body }) }); + if (!res.ok) return normalizeErrorResponse(res); + if (forcedStream) return aggregateSseResponse(res); + return res; +}; + +/** + * The SDK must BELIEVE store is false — forcing it only on the wire is not + * enough. When the SDK thinks store is on (its default), it echoes prior + * assistant text/reasoning parts as `item_reference` entries, and the + * stateless Codex backend 404s them ("Items are not persisted when `store` + * is set to false"). With providerOptions.openai.store=false its message + * converter emits full items instead — text with content, reasoning with + * the round-tripped encrypted_content — so every request is + * self-contained. Injected via middleware so no caller has to know. + */ +export const codexStoreMiddleware: LanguageModelV4Middleware = { + specificationVersion: 'v4', + transformParams: async ({ params }) => ({ + ...params, + providerOptions: { + ...params.providerOptions, + openai: { + ...params.providerOptions?.openai, + store: false, + }, + }, + }), +}; + +/** + * AI SDK provider for the codex flavor. `languageModel()` on the OpenAI + * provider resolves to the Responses API model (the only API the Codex + * backend speaks); auth is injected per request by codexFetch, so the + * apiKey here is a placeholder that never reaches the wire. + */ +export function getCodexProvider(): ProviderV4 { + const provider = createOpenAI({ + baseURL: CODEX_BASE_URL, + apiKey: 'chatgpt-subscription', + fetch: codexFetch, + }); + return { + ...provider, + languageModel: (modelId: string) => wrapLanguageModel({ + model: provider.languageModel(modelId), + middleware: codexStoreMiddleware, + }), + }; +} + +type ProviderSummary = { + id: string; + name: string; + models: Array<{ + id: string; + name?: string; + reasoning?: boolean; + }>; +}; + +/** + * Models available to the signed-in ChatGPT subscription, shaped like + * listGatewayModels for the models:list merge. Live discovery against the + * backend's own catalog; the hardcoded fallback covers offline/errors. + * Every codex model is a reasoning model, so the flag is set directly + * (models.dev doesn't know this flavor). + */ +export async function listCodexModels(): Promise<{ providers: ProviderSummary[] }> { + let discovered: Array<{ id: string; name?: string }> = []; + try { + const res = await codexFetch(`${CODEX_BASE_URL}/models?client_version=${await codexClientVersion()}`); + if (res.ok) { + const body = await res.json() as { + models?: Array<{ slug?: string; display_name?: string; visibility?: string; priority?: number }>; + }; + discovered = (body.models ?? []) + // Utility models (e.g. codex-auto-review) carry visibility + // "hide"; picker-visible ones carry "list". + .filter((m): m is { slug: string; display_name?: string; priority?: number } => + typeof m.slug === 'string' && m.slug.length > 0 + && m.visibility !== 'hide' && m.visibility !== 'hidden') + .sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER)) + .map((m) => ({ id: m.slug, ...(m.display_name ? { name: m.display_name } : {}) })); + } + } catch (error) { + console.warn('[Codex] Model discovery failed; using fallback list:', error instanceof Error ? error.message : error); + } + const models = (discovered.length > 0 ? discovered : CODEX_FALLBACK_MODELS.map((id) => ({ id }))) + .map((m) => ({ ...m, reasoning: true })); + return { + providers: [{ + id: 'codex', + name: 'OpenAI Codex', + models, + }], + }; +} diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 183faeac..89527ebf 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -78,6 +78,11 @@ export async function resolveProviderConfig(name: string): Promise("modelConfigRepo"); const cfg = await repo.getConfig(); const entry = cfg.providers?.[name]; diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index e124c924..a87bd52d 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -9,6 +9,7 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js"; import z from "zod"; import { getGatewayProvider } from "./gateway.js"; +import { getCodexProvider } from "./codex.js"; import { getDefaultModelAndProvider, resolveProviderConfig } from "./defaults.js"; import { getChatModelIds } from "./models-dev.js"; import { withUseCase } from "../analytics/use_case.js"; @@ -81,6 +82,8 @@ export function createProvider(config: z.infer): ProviderV4 { }) as unknown as ProviderV4; case "rowboat": return getGatewayProvider(); + case "codex": + return getCodexProvider(); default: throw new Error(`Unsupported provider flavor: ${config.flavor}`); } diff --git a/apps/x/packages/core/src/models/reasoning.test.ts b/apps/x/packages/core/src/models/reasoning.test.ts index ad6af975..eff171c2 100644 --- a/apps/x/packages/core/src/models/reasoning.test.ts +++ b/apps/x/packages/core/src/models/reasoning.test.ts @@ -68,6 +68,13 @@ describe("mapReasoningEffort", () => { expect(mapReasoningEffort("rowboat", "x/y", "high", false)).toBeUndefined(); }); + it("maps codex like openai but forgiving on unknown support (models.dev has no codex flavor)", () => { + expect(mapReasoningEffort("codex", "gpt-5.5", "high", undefined)).toEqual({ + providerOptions: { openai: { reasoningEffort: "high" } }, + }); + expect(mapReasoningEffort("codex", "gpt-5.5", "medium", false)).toBeUndefined(); + }); + it("sends nothing for flavors without a safe parameter", () => { expect(mapReasoningEffort("ollama", "gpt-oss", "high", true)).toBeUndefined(); expect(mapReasoningEffort("openai-compatible", "my-vllm-model", "high", true)).toBeUndefined(); diff --git a/apps/x/packages/core/src/models/reasoning.ts b/apps/x/packages/core/src/models/reasoning.ts index c85e09c3..9d3e2480 100644 --- a/apps/x/packages/core/src/models/reasoning.ts +++ b/apps/x/packages/core/src/models/reasoning.ts @@ -95,6 +95,13 @@ export function mapReasoningEffort( if (supportsReasoning === false) return undefined; return { providerOptions: { openrouter: { reasoning: { effort } } } }; } + case "codex": { + // Every ChatGPT-subscription model reasons, but models.dev has + // no "codex" flavor so support reads as unknown — map unless + // known-false rather than failing closed. + if (supportsReasoning === false) return undefined; + return { providerOptions: { openai: { reasoningEffort: effort } } }; + } default: return undefined; } diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index fc29e83b..2d657b77 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -821,6 +821,15 @@ const ipcSchemas = { success: z.boolean(), }), }, + // Push event (main → renderer): ChatGPT sign-in state changed. Model + // pickers listen and refresh — subscription models appear/disappear with + // the session. + 'chatgpt:statusChanged': { + req: z.object({ + signedIn: z.boolean(), + }), + res: z.null(), + }, 'app:openUrl': { req: z.object({ url: z.string(), diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 5ed03104..f929491f 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -9,7 +9,10 @@ import { z } from "zod"; export const ReasoningEffort = z.enum(["low", "medium", "high"]); export const LlmProvider = z.object({ - flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat"]), + // "rowboat" (signed-in gateway) and "codex" (ChatGPT subscription via + // "Sign in with ChatGPT") are credential-less flavors: they never appear + // in models.json's providers map — auth lives in their own token stores. + flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat", "codex"]), apiKey: z.string().optional(), baseURL: z.string().optional(), headers: z.record(z.string(), z.string()).optional(),