diff --git a/apps/cli/src/commands/auth/index.ts b/apps/cli/src/commands/auth/index.ts index 52ae7673a7e..40523f27fa2 100644 --- a/apps/cli/src/commands/auth/index.ts +++ b/apps/cli/src/commands/auth/index.ts @@ -1,3 +1,6 @@ export * from "./login.js" export * from "./logout.js" export * from "./status.js" +export * from "./openai-codex-login.js" +export * from "./openai-codex-logout.js" +export * from "./openai-codex-status.js" diff --git a/apps/cli/src/commands/auth/openai-codex-login.ts b/apps/cli/src/commands/auth/openai-codex-login.ts new file mode 100644 index 00000000000..a890a49c88b --- /dev/null +++ b/apps/cli/src/commands/auth/openai-codex-login.ts @@ -0,0 +1,324 @@ +import * as crypto from "crypto" +import * as http from "http" +import { URL } from "url" +import { exec } from "child_process" + +import { saveOpenAiCodexCredentials, OpenAiCodexCredentials } from "@/lib/storage/openai-codex-credentials.js" + +/** + * OpenAI Codex OAuth Configuration + * Matches the config in src/integrations/openai-codex/oauth.ts + */ +const OPENAI_CODEX_OAUTH_CONFIG = { + authorizationEndpoint: "https://auth.openai.com/oauth/authorize", + tokenEndpoint: "https://auth.openai.com/oauth/token", + clientId: "app_EMoamEEZ73f0CkXaXp7hrann", + redirectUri: "http://localhost:1455/auth/callback", + scopes: "openid profile email offline_access", + callbackPort: 1455, +} as const + +export interface OpenAiCodexLoginOptions { + timeout?: number + verbose?: boolean +} + +export type OpenAiCodexLoginResult = { success: true; email?: string } | { success: false; error: string } + +/** + * JWT claims structure for extracting ChatGPT account ID + */ +interface IdTokenClaims { + chatgpt_account_id?: string + organizations?: Array<{ id: string }> + email?: string + "https://api.openai.com/auth"?: { + chatgpt_account_id?: string + } +} + +function parseJwtClaims(token: string): IdTokenClaims | undefined { + const parts = token.split(".") + if (parts.length !== 3 || !parts[1]) return undefined + try { + const payload = Buffer.from(parts[1], "base64url").toString("utf-8") + return JSON.parse(payload) as IdTokenClaims + } catch { + return undefined + } +} + +function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined { + return ( + claims.chatgpt_account_id || + claims["https://api.openai.com/auth"]?.chatgpt_account_id || + claims.organizations?.[0]?.id + ) +} + +function extractAccountId(tokens: { id_token?: string; access_token: string }): string | undefined { + if (tokens.id_token) { + const claims = parseJwtClaims(tokens.id_token) + const accountId = claims && extractAccountIdFromClaims(claims) + if (accountId) return accountId + } + if (tokens.access_token) { + const claims = parseJwtClaims(tokens.access_token) + return claims ? extractAccountIdFromClaims(claims) : undefined + } + return undefined +} + +function generateCodeVerifier(): string { + return crypto.randomBytes(32).toString("base64url") +} + +function generateCodeChallenge(verifier: string): string { + return crypto.createHash("sha256").update(verifier).digest().toString("base64url") +} + +function generateState(): string { + return crypto.randomBytes(16).toString("hex") +} + +function buildAuthorizationUrl(codeChallenge: string, state: string): string { + const params = new URLSearchParams({ + client_id: OPENAI_CODEX_OAUTH_CONFIG.clientId, + redirect_uri: OPENAI_CODEX_OAUTH_CONFIG.redirectUri, + scope: OPENAI_CODEX_OAUTH_CONFIG.scopes, + code_challenge: codeChallenge, + code_challenge_method: "S256", + response_type: "code", + state, + codex_cli_simplified_flow: "true", + originator: "roo-code", + }) + return `${OPENAI_CODEX_OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}` +} + +async function exchangeCodeForTokens(code: string, codeVerifier: string): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: OPENAI_CODEX_OAUTH_CONFIG.clientId, + code, + redirect_uri: OPENAI_CODEX_OAUTH_CONFIG.redirectUri, + code_verifier: codeVerifier, + }) + + const response = await fetch(OPENAI_CODEX_OAUTH_CONFIG.tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + const data = await response.json() + + if (!data.access_token || !data.refresh_token) { + throw new Error("Token exchange did not return required tokens") + } + + const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000 + const accountId = extractAccountId({ + id_token: data.id_token, + access_token: data.access_token, + }) + + return { + type: "openai-codex", + access_token: data.access_token, + refresh_token: data.refresh_token, + expires: expiresAt, + email: data.email, + accountId, + } +} + +function openBrowser(url: string): Promise { + return new Promise((resolve, reject) => { + const platform = process.platform + let command: string + + switch (platform) { + case "darwin": + command = `open "${url}"` + break + case "win32": + command = `start "" "${url}"` + break + default: + command = `xdg-open "${url}"` + break + } + + exec(command, (error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} + +export async function openaiCodexLogin({ + timeout = 5 * 60 * 1000, + verbose = false, +}: OpenAiCodexLoginOptions = {}): Promise { + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const state = generateState() + + if (verbose) { + console.log(`[Auth] Starting OpenAI Codex OAuth flow on port ${OPENAI_CODEX_OAUTH_CONFIG.callbackPort}`) + } + + const credentialsPromise = new Promise((resolve, reject) => { + const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url || "", `http://localhost:${OPENAI_CODEX_OAUTH_CONFIG.callbackPort}`) + + if (url.pathname !== "/auth/callback") { + res.writeHead(404) + res.end("Not Found") + return + } + + const code = url.searchParams.get("code") + const receivedState = url.searchParams.get("state") + const error = url.searchParams.get("error") + + if (error) { + res.writeHead(400) + res.end(`Authentication failed: ${error}`) + reject(new Error(`OAuth error: ${error}`)) + server.close() + return + } + + if (!code || !receivedState) { + res.writeHead(400) + res.end("Missing code or state parameter") + reject(new Error("Missing code or state parameter")) + server.close() + return + } + + if (receivedState !== state) { + res.writeHead(400) + res.end("State mismatch - possible CSRF attack") + reject(new Error("State mismatch")) + server.close() + return + } + + try { + const credentials = await exchangeCodeForTokens(code, codeVerifier) + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + res.end(` + + + +Authentication Successful + + + +
+

✓ Authentication Successful

+

You can close this window and return to the terminal.

+
+ + +`) + + server.close() + resolve(credentials) + } catch (exchangeError) { + res.writeHead(500) + res.end(`Token exchange failed: ${exchangeError}`) + reject(exchangeError) + server.close() + } + } catch (err) { + res.writeHead(500) + res.end("Internal server error") + reject(err) + server.close() + } + }) + + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + reject( + new Error( + `Port ${OPENAI_CODEX_OAUTH_CONFIG.callbackPort} is already in use. ` + + `Please close any other applications using this port and try again.`, + ), + ) + } else { + reject(err) + } + }) + + const timeoutId = setTimeout(() => { + server.close() + reject(new Error("Authentication timed out")) + }, timeout) + + server.listen(OPENAI_CODEX_OAUTH_CONFIG.callbackPort, () => { + if (verbose) { + console.log(`[Auth] Callback server listening on port ${OPENAI_CODEX_OAUTH_CONFIG.callbackPort}`) + } + }) + + server.on("close", () => { + clearTimeout(timeoutId) + }) + }) + + const authUrl = buildAuthorizationUrl(codeChallenge, state) + + console.log("Opening browser for OpenAI authentication...") + console.log(`If the browser doesn't open, visit: ${authUrl}`) + + try { + await openBrowser(authUrl) + } catch (error) { + if (verbose) { + console.warn("[Auth] Failed to open browser automatically:", error) + } + console.log("Please open the URL above in your browser manually.") + } + + try { + const credentials = await credentialsPromise + await saveOpenAiCodexCredentials(credentials) + const emailInfo = credentials.email ? ` (${credentials.email})` : "" + console.log(`✓ Successfully authenticated with OpenAI${emailInfo}`) + return { success: true, email: credentials.email } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`✗ OpenAI authentication failed: ${message}`) + return { success: false, error: message } + } +} diff --git a/apps/cli/src/commands/auth/openai-codex-logout.ts b/apps/cli/src/commands/auth/openai-codex-logout.ts new file mode 100644 index 00000000000..2c966f9e7e3 --- /dev/null +++ b/apps/cli/src/commands/auth/openai-codex-logout.ts @@ -0,0 +1,29 @@ +import { clearOpenAiCodexCredentials, hasOpenAiCodexCredentials } from "@/lib/storage/openai-codex-credentials.js" + +export interface OpenAiCodexLogoutOptions { + verbose?: boolean +} + +export interface OpenAiCodexLogoutResult { + success: boolean + wasLoggedIn: boolean +} + +export async function openaiCodexLogout({ + verbose = false, +}: OpenAiCodexLogoutOptions = {}): Promise { + const wasLoggedIn = await hasOpenAiCodexCredentials() + + if (!wasLoggedIn) { + console.log("You are not currently logged in to OpenAI Codex.") + return { success: true, wasLoggedIn: false } + } + + if (verbose) { + console.log("[Auth] Removing OpenAI Codex OAuth credentials") + } + + await clearOpenAiCodexCredentials() + console.log("✓ Successfully logged out from OpenAI Codex") + return { success: true, wasLoggedIn: true } +} diff --git a/apps/cli/src/commands/auth/openai-codex-status.ts b/apps/cli/src/commands/auth/openai-codex-status.ts new file mode 100644 index 00000000000..40e8a1a8df8 --- /dev/null +++ b/apps/cli/src/commands/auth/openai-codex-status.ts @@ -0,0 +1,74 @@ +import { loadOpenAiCodexCredentials, isCredentialsExpired } from "@/lib/storage/openai-codex-credentials.js" + +export interface OpenAiCodexStatusOptions { + verbose?: boolean +} + +export interface OpenAiCodexStatusResult { + authenticated: boolean + expired?: boolean + email?: string + expiresAt?: Date +} + +export async function openaiCodexStatus({ + verbose = false, +}: OpenAiCodexStatusOptions = {}): Promise { + const credentials = await loadOpenAiCodexCredentials() + + if (!credentials) { + console.log("✗ Not authenticated with OpenAI Codex") + console.log("") + console.log("Run: roo auth login-openai") + return { authenticated: false } + } + + const expired = isCredentialsExpired(credentials) + const expiresAt = new Date(credentials.expires) + + if (expired) { + console.log("⚠ OpenAI Codex access token expired (will auto-refresh on next use)") + } else { + console.log("✓ Authenticated with OpenAI Codex") + } + + if (credentials.email) { + console.log(` Email: ${credentials.email}`) + } + + const remaining = getTimeRemaining(expiresAt) + console.log(` Token: ${expired ? "expired" : `expires ${formatDate(expiresAt)} (${remaining})`}`) + + if (verbose && credentials.accountId) { + console.log(` Account ID: ${credentials.accountId}`) + } + + return { + authenticated: true, + expired, + email: credentials.email, + expiresAt, + } +} + +function formatDate(date: Date): string { + return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }) +} + +function getTimeRemaining(date: Date): string { + const now = new Date() + const diff = date.getTime() - now.getTime() + + if (diff <= 0) { + return "expired" + } + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)) + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + + if (days > 0) { + return `${days} day${days === 1 ? "" : "s"}` + } + + return `${hours} hour${hours === 1 ? "" : "s"}` +} diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 62760919e7e..7fd6139259c 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -22,6 +22,7 @@ import { JsonEventEmitter } from "@/agent/json-event-emitter.js" import { createClient } from "@/lib/sdk/index.js" import { loadToken, loadSettings } from "@/lib/storage/index.js" +import { hasOpenAiCodexCredentials } from "@/lib/storage/openai-codex-credentials.js" import { readWorkspaceTaskSessions, resolveWorkspaceResumeSessionId } from "@/lib/task-history/index.js" import { isRecord } from "@/lib/utils/guards.js" import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js" @@ -272,6 +273,20 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption // which will check flagOptions.apiKey and ROO_API_KEY env var. } + // OpenAI Codex OAuth Authentication + if (extensionHostOptions.provider === "openai-codex") { + const hasCodexCreds = await hasOpenAiCodexCredentials() + if (!hasCodexCreds) { + console.error("[CLI] Error: Not authenticated with OpenAI Codex.") + console.error("[CLI] Please run: roo auth login-openai") + process.exit(1) + } + // OpenAI Codex uses OAuth tokens managed by the extension's openAiCodexOAuthManager. + // Credentials are stored in the vscode-shim secret storage and loaded automatically. + // Set a placeholder so the API key check below doesn't fail. + extensionHostOptions.apiKey = "openai-codex-oauth" + } + // Validations // TODO: Validate the API key for the chosen provider. // TODO: Validate the model for the chosen provider. diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 2805e6c9099..e711b1631b5 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -7,6 +7,9 @@ import { login, logout, status, + openaiCodexLogin, + openaiCodexLogout, + openaiCodexStatus, listCommands, listModes, listModels, @@ -136,7 +139,7 @@ program await runUpgradeAction(() => upgrade()) }) -const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud") +const authCommand = program.command("auth").description("Manage authentication") authCommand .command("login") @@ -165,4 +168,31 @@ authCommand process.exit(result.authenticated ? 0 : 1) }) +authCommand + .command("login-openai") + .description("Authenticate with OpenAI (ChatGPT Plus/Pro subscription via OAuth)") + .option("-v, --verbose", "Enable verbose output", false) + .action(async (options: { verbose: boolean }) => { + const result = await openaiCodexLogin({ verbose: options.verbose }) + process.exit(result.success ? 0 : 1) + }) + +authCommand + .command("logout-openai") + .description("Log out from OpenAI Codex") + .option("-v, --verbose", "Enable verbose output", false) + .action(async (options: { verbose: boolean }) => { + const result = await openaiCodexLogout({ verbose: options.verbose }) + process.exit(result.success ? 0 : 1) + }) + +authCommand + .command("status-openai") + .description("Show OpenAI Codex authentication status") + .option("-v, --verbose", "Enable verbose output", false) + .action(async (options: { verbose: boolean }) => { + const result = await openaiCodexStatus({ verbose: options.verbose }) + process.exit(result.authenticated ? 0 : 1) + }) + program.parse() diff --git a/apps/cli/src/lib/storage/__tests__/openai-codex-credentials.test.ts b/apps/cli/src/lib/storage/__tests__/openai-codex-credentials.test.ts new file mode 100644 index 00000000000..8843967f906 --- /dev/null +++ b/apps/cli/src/lib/storage/__tests__/openai-codex-credentials.test.ts @@ -0,0 +1,150 @@ +import fs from "fs/promises" +import path from "path" +import os from "os" + +import { + saveOpenAiCodexCredentials, + loadOpenAiCodexCredentials, + clearOpenAiCodexCredentials, + hasOpenAiCodexCredentials, + isCredentialsExpired, + type OpenAiCodexCredentials, +} from "../openai-codex-credentials.js" + +// Override the HOME env to use a temp directory for tests +const testDir = path.join(os.tmpdir(), `roo-cli-codex-test-${Date.now()}`) +const originalHome = process.env.HOME + +beforeAll(() => { + process.env.HOME = testDir +}) + +afterAll(async () => { + process.env.HOME = originalHome + await fs.rm(testDir, { recursive: true, force: true }) +}) + +beforeEach(async () => { + // Clean secrets between tests + const secretsPath = path.join(testDir, ".vscode-mock", "global-storage", "secrets.json") + try { + await fs.unlink(secretsPath) + } catch { + // file may not exist + } +}) + +const validCredentials: OpenAiCodexCredentials = { + type: "openai-codex", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + expires: Date.now() + 3600 * 1000, + email: "test@example.com", + accountId: "acct_123", +} + +describe("OpenAI Codex Credentials Storage", () => { + describe("saveOpenAiCodexCredentials", () => { + it("should save credentials to secrets file", async () => { + await saveOpenAiCodexCredentials(validCredentials) + + const secretsPath = path.join(testDir, ".vscode-mock", "global-storage", "secrets.json") + const raw = await fs.readFile(secretsPath, "utf-8") + const secrets = JSON.parse(raw) + + expect(secrets["openai-codex-oauth-credentials"]).toBeDefined() + const stored = JSON.parse(secrets["openai-codex-oauth-credentials"]) + expect(stored.type).toBe("openai-codex") + expect(stored.access_token).toBe("test-access-token") + expect(stored.refresh_token).toBe("test-refresh-token") + }) + + it("should create directory structure if it doesn't exist", async () => { + await saveOpenAiCodexCredentials(validCredentials) + + const dirPath = path.join(testDir, ".vscode-mock", "global-storage") + const stats = await fs.stat(dirPath) + expect(stats.isDirectory()).toBe(true) + }) + }) + + describe("loadOpenAiCodexCredentials", () => { + it("should load saved credentials", async () => { + await saveOpenAiCodexCredentials(validCredentials) + + const loaded = await loadOpenAiCodexCredentials() + + expect(loaded).not.toBeNull() + expect(loaded!.type).toBe("openai-codex") + expect(loaded!.access_token).toBe("test-access-token") + expect(loaded!.refresh_token).toBe("test-refresh-token") + expect(loaded!.email).toBe("test@example.com") + expect(loaded!.accountId).toBe("acct_123") + }) + + it("should return null when no credentials exist", async () => { + const loaded = await loadOpenAiCodexCredentials() + expect(loaded).toBeNull() + }) + + it("should return null for invalid credentials data", async () => { + const secretsPath = path.join(testDir, ".vscode-mock", "global-storage", "secrets.json") + await fs.mkdir(path.dirname(secretsPath), { recursive: true }) + await fs.writeFile(secretsPath, JSON.stringify({ "openai-codex-oauth-credentials": "invalid-json" })) + + const loaded = await loadOpenAiCodexCredentials() + expect(loaded).toBeNull() + }) + }) + + describe("clearOpenAiCodexCredentials", () => { + it("should remove credentials", async () => { + await saveOpenAiCodexCredentials(validCredentials) + expect(await hasOpenAiCodexCredentials()).toBe(true) + + await clearOpenAiCodexCredentials() + expect(await hasOpenAiCodexCredentials()).toBe(false) + }) + + it("should not throw when no credentials exist", async () => { + await expect(clearOpenAiCodexCredentials()).resolves.not.toThrow() + }) + }) + + describe("hasOpenAiCodexCredentials", () => { + it("should return false when no credentials exist", async () => { + expect(await hasOpenAiCodexCredentials()).toBe(false) + }) + + it("should return true when credentials exist", async () => { + await saveOpenAiCodexCredentials(validCredentials) + expect(await hasOpenAiCodexCredentials()).toBe(true) + }) + }) + + describe("isCredentialsExpired", () => { + it("should return false for unexpired credentials", () => { + const creds: OpenAiCodexCredentials = { + ...validCredentials, + expires: Date.now() + 60 * 60 * 1000, // 1 hour from now + } + expect(isCredentialsExpired(creds)).toBe(false) + }) + + it("should return true for expired credentials", () => { + const creds: OpenAiCodexCredentials = { + ...validCredentials, + expires: Date.now() - 1000, // already expired + } + expect(isCredentialsExpired(creds)).toBe(true) + }) + + it("should return true when within 5-minute buffer", () => { + const creds: OpenAiCodexCredentials = { + ...validCredentials, + expires: Date.now() + 2 * 60 * 1000, // 2 minutes from now (within 5 min buffer) + } + expect(isCredentialsExpired(creds)).toBe(true) + }) + }) +}) diff --git a/apps/cli/src/lib/storage/index.ts b/apps/cli/src/lib/storage/index.ts index 53424472c2a..4d8772abab7 100644 --- a/apps/cli/src/lib/storage/index.ts +++ b/apps/cli/src/lib/storage/index.ts @@ -2,3 +2,4 @@ export * from "./config-dir.js" export * from "./settings.js" export * from "./credentials.js" export * from "./ephemeral.js" +export * from "./openai-codex-credentials.js" diff --git a/apps/cli/src/lib/storage/openai-codex-credentials.ts b/apps/cli/src/lib/storage/openai-codex-credentials.ts new file mode 100644 index 00000000000..4a12b31c505 --- /dev/null +++ b/apps/cli/src/lib/storage/openai-codex-credentials.ts @@ -0,0 +1,108 @@ +import fs from "fs/promises" +import path from "path" + +/** + * OpenAI Codex OAuth credentials storage for CLI. + * + * Stores credentials in the same location as the vscode-shim's FileSecretStorage + * (~/.vscode-mock/global-storage/secrets.json) so the existing OpenAiCodexOAuthManager + * can find them transparently when the extension loads. + */ + +const STORAGE_BASE_DIR = ".vscode-mock" +const SECRETS_KEY = "openai-codex-oauth-credentials" + +// Credentials type (matches src/integrations/openai-codex/oauth.ts) +export interface OpenAiCodexCredentials { + type: "openai-codex" + access_token: string + refresh_token: string + expires: number + email?: string + accountId?: string +} + +function isValidCredentials(obj: unknown): obj is OpenAiCodexCredentials { + if (!obj || typeof obj !== "object") return false + const o = obj as Record + return ( + o.type === "openai-codex" && + typeof o.access_token === "string" && + o.access_token.length > 0 && + typeof o.refresh_token === "string" && + o.refresh_token.length > 0 && + typeof o.expires === "number" + ) +} + +function getSecretsFilePath(): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || "." + return path.join(homeDir, STORAGE_BASE_DIR, "global-storage", "secrets.json") +} + +async function readSecretsFile(): Promise> { + try { + const data = await fs.readFile(getSecretsFilePath(), "utf-8") + return JSON.parse(data) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return {} + } + throw error + } +} + +async function writeSecretsFile(secrets: Record): Promise { + const filePath = getSecretsFilePath() + const dir = path.dirname(filePath) + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(filePath, JSON.stringify(secrets, null, 2), { mode: 0o600 }) +} + +export async function saveOpenAiCodexCredentials(credentials: OpenAiCodexCredentials): Promise { + const secrets = await readSecretsFile() + secrets[SECRETS_KEY] = JSON.stringify(credentials) + await writeSecretsFile(secrets) +} + +export async function loadOpenAiCodexCredentials(): Promise { + try { + const secrets = await readSecretsFile() + const raw = secrets[SECRETS_KEY] + if (!raw) { + return null + } + const parsed = JSON.parse(raw) + if (!isValidCredentials(parsed)) { + return null + } + return parsed + } catch { + return null + } +} + +export async function clearOpenAiCodexCredentials(): Promise { + try { + const secrets = await readSecretsFile() + delete secrets[SECRETS_KEY] + await writeSecretsFile(secrets) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error + } + } +} + +export async function hasOpenAiCodexCredentials(): Promise { + const creds = await loadOpenAiCodexCredentials() + return creds !== null +} + +/** + * Check if the stored credentials have an expired access token (with 5 min buffer). + */ +export function isCredentialsExpired(credentials: OpenAiCodexCredentials): boolean { + const bufferMs = 5 * 60 * 1000 + return Date.now() >= credentials.expires - bufferMs +} diff --git a/apps/cli/src/lib/utils/provider.ts b/apps/cli/src/lib/utils/provider.ts index 64aec430c1b..567a8bc4b1d 100644 --- a/apps/cli/src/lib/utils/provider.ts +++ b/apps/cli/src/lib/utils/provider.ts @@ -5,6 +5,7 @@ import type { SupportedProvider } from "@/types/index.js" const envVarMap: Record = { anthropic: "ANTHROPIC_API_KEY", "openai-native": "OPENAI_API_KEY", + "openai-codex": "", // No API key - uses OAuth gemini: "GOOGLE_API_KEY", openrouter: "OPENROUTER_API_KEY", "vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY", @@ -36,6 +37,11 @@ export function getProviderSettings( if (apiKey) config.openAiNativeApiKey = apiKey if (model) config.apiModelId = model break + case "openai-codex": + // OpenAI Codex uses OAuth, not API keys. + // The OpenAiCodexHandler gets its token from openAiCodexOAuthManager. + if (model) config.apiModelId = model + break case "gemini": if (apiKey) config.geminiApiKey = apiKey if (model) config.apiModelId = model diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index ecd3922aa1c..ae4d25c38b9 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -4,6 +4,7 @@ import type { OutputFormat } from "./json-events.js" export const supportedProviders = [ "anthropic", "openai-native", + "openai-codex", "gemini", "openrouter", "vercel-ai-gateway",