mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
Merge 60faa593a1 into 7adbfec2a4
This commit is contained in:
commit
318090bcf0
11 changed files with 742 additions and 1 deletions
|
|
@ -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"
|
||||
|
|
|
|||
324
apps/cli/src/commands/auth/openai-codex-login.ts
Normal file
324
apps/cli/src/commands/auth/openai-codex-login.ts
Normal file
|
|
@ -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<OpenAiCodexCredentials> {
|
||||
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<void> {
|
||||
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<OpenAiCodexLoginResult> {
|
||||
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<OpenAiCodexCredentials>((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(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Authentication Successful</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #10a37f 0%, #0d8f6f 100%);
|
||||
color: white;
|
||||
}
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { font-size: 2rem; margin-bottom: 1rem; }
|
||||
p { opacity: 0.9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>✓ Authentication Successful</h1>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>`)
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
29
apps/cli/src/commands/auth/openai-codex-logout.ts
Normal file
29
apps/cli/src/commands/auth/openai-codex-logout.ts
Normal file
|
|
@ -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<OpenAiCodexLogoutResult> {
|
||||
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 }
|
||||
}
|
||||
74
apps/cli/src/commands/auth/openai-codex-status.ts
Normal file
74
apps/cli/src/commands/auth/openai-codex-status.ts
Normal file
|
|
@ -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<OpenAiCodexStatusResult> {
|
||||
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"}`
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
108
apps/cli/src/lib/storage/openai-codex-credentials.ts
Normal file
108
apps/cli/src/lib/storage/openai-codex-credentials.ts
Normal file
|
|
@ -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<string, unknown>
|
||||
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<Record<string, string>> {
|
||||
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<string, string>): Promise<void> {
|
||||
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<void> {
|
||||
const secrets = await readSecretsFile()
|
||||
secrets[SECRETS_KEY] = JSON.stringify(credentials)
|
||||
await writeSecretsFile(secrets)
|
||||
}
|
||||
|
||||
export async function loadOpenAiCodexCredentials(): Promise<OpenAiCodexCredentials | null> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import type { SupportedProvider } from "@/types/index.js"
|
|||
const envVarMap: Record<SupportedProvider, string> = {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { OutputFormat } from "./json-events.js"
|
|||
export const supportedProviders = [
|
||||
"anthropic",
|
||||
"openai-native",
|
||||
"openai-codex",
|
||||
"gemini",
|
||||
"openrouter",
|
||||
"vercel-ai-gateway",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue