|
|
@@ -0,0 +1,417 @@
|
|
|
+import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
|
|
+import { Log } from "../util/log"
|
|
|
+import { OAUTH_DUMMY_KEY } from "../auth"
|
|
|
+
|
|
|
+const log = Log.create({ service: "plugin.codex" })
|
|
|
+
|
|
|
+const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
|
+const ISSUER = "https://auth.openai.com"
|
|
|
+const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
|
|
|
+const OAUTH_PORT = 1455
|
|
|
+
|
|
|
+interface PkceCodes {
|
|
|
+ verifier: string
|
|
|
+ challenge: string
|
|
|
+}
|
|
|
+
|
|
|
+async function generatePKCE(): Promise<PkceCodes> {
|
|
|
+ const verifier = generateRandomString(43)
|
|
|
+ const encoder = new TextEncoder()
|
|
|
+ const data = encoder.encode(verifier)
|
|
|
+ const hash = await crypto.subtle.digest("SHA-256", data)
|
|
|
+ const challenge = base64UrlEncode(hash)
|
|
|
+ return { verifier, challenge }
|
|
|
+}
|
|
|
+
|
|
|
+function generateRandomString(length: number): string {
|
|
|
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
|
+ const bytes = crypto.getRandomValues(new Uint8Array(length))
|
|
|
+ return Array.from(bytes)
|
|
|
+ .map((b) => chars[b % chars.length])
|
|
|
+ .join("")
|
|
|
+}
|
|
|
+
|
|
|
+function base64UrlEncode(buffer: ArrayBuffer): string {
|
|
|
+ const bytes = new Uint8Array(buffer)
|
|
|
+ const binary = String.fromCharCode(...bytes)
|
|
|
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
|
|
+}
|
|
|
+
|
|
|
+function generateState(): string {
|
|
|
+ return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
|
|
+}
|
|
|
+
|
|
|
+function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
|
|
|
+ const params = new URLSearchParams({
|
|
|
+ response_type: "code",
|
|
|
+ client_id: CLIENT_ID,
|
|
|
+ redirect_uri: redirectUri,
|
|
|
+ scope: "openid profile email offline_access",
|
|
|
+ code_challenge: pkce.challenge,
|
|
|
+ code_challenge_method: "S256",
|
|
|
+ id_token_add_organizations: "true",
|
|
|
+ codex_cli_simplified_flow: "true",
|
|
|
+ state,
|
|
|
+ originator: "opencode",
|
|
|
+ })
|
|
|
+ return `${ISSUER}/oauth/authorize?${params.toString()}`
|
|
|
+}
|
|
|
+
|
|
|
+interface TokenResponse {
|
|
|
+ id_token: string
|
|
|
+ access_token: string
|
|
|
+ refresh_token: string
|
|
|
+ expires_in?: number
|
|
|
+}
|
|
|
+
|
|
|
+async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
|
|
+ const response = await fetch(`${ISSUER}/oauth/token`, {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
|
+ body: new URLSearchParams({
|
|
|
+ grant_type: "authorization_code",
|
|
|
+ code,
|
|
|
+ redirect_uri: redirectUri,
|
|
|
+ client_id: CLIENT_ID,
|
|
|
+ code_verifier: pkce.verifier,
|
|
|
+ }).toString(),
|
|
|
+ })
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error(`Token exchange failed: ${response.status}`)
|
|
|
+ }
|
|
|
+ return response.json()
|
|
|
+}
|
|
|
+
|
|
|
+async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
|
|
|
+ const response = await fetch(`${ISSUER}/oauth/token`, {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
|
+ body: new URLSearchParams({
|
|
|
+ grant_type: "refresh_token",
|
|
|
+ refresh_token: refreshToken,
|
|
|
+ client_id: CLIENT_ID,
|
|
|
+ }).toString(),
|
|
|
+ })
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error(`Token refresh failed: ${response.status}`)
|
|
|
+ }
|
|
|
+ return response.json()
|
|
|
+}
|
|
|
+
|
|
|
+const HTML_SUCCESS = `<!DOCTYPE html>
|
|
|
+<html>
|
|
|
+<head>
|
|
|
+ <title>OpenCode - Codex Authorization Successful</title>
|
|
|
+ <style>
|
|
|
+ body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
|
|
+ .container { text-align: center; padding: 2rem; }
|
|
|
+ h1 { color: #4ade80; margin-bottom: 1rem; }
|
|
|
+ p { color: #aaa; }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <div class="container">
|
|
|
+ <h1>Authorization Successful</h1>
|
|
|
+ <p>You can close this window and return to OpenCode.</p>
|
|
|
+ </div>
|
|
|
+ <script>setTimeout(() => window.close(), 2000);</script>
|
|
|
+</body>
|
|
|
+</html>`
|
|
|
+
|
|
|
+const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
|
|
+<html>
|
|
|
+<head>
|
|
|
+ <title>OpenCode - Codex Authorization Failed</title>
|
|
|
+ <style>
|
|
|
+ body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
|
|
+ .container { text-align: center; padding: 2rem; }
|
|
|
+ h1 { color: #f87171; margin-bottom: 1rem; }
|
|
|
+ p { color: #aaa; }
|
|
|
+ .error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <div class="container">
|
|
|
+ <h1>Authorization Failed</h1>
|
|
|
+ <p>An error occurred during authorization.</p>
|
|
|
+ <div class="error">${error}</div>
|
|
|
+ </div>
|
|
|
+</body>
|
|
|
+</html>`
|
|
|
+
|
|
|
+interface PendingOAuth {
|
|
|
+ pkce: PkceCodes
|
|
|
+ state: string
|
|
|
+ resolve: (tokens: TokenResponse) => void
|
|
|
+ reject: (error: Error) => void
|
|
|
+}
|
|
|
+
|
|
|
+let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
|
|
+let pendingOAuth: PendingOAuth | undefined
|
|
|
+
|
|
|
+async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
|
|
+ if (oauthServer) {
|
|
|
+ return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
|
|
+ }
|
|
|
+
|
|
|
+ oauthServer = Bun.serve({
|
|
|
+ port: OAUTH_PORT,
|
|
|
+ fetch(req) {
|
|
|
+ const url = new URL(req.url)
|
|
|
+
|
|
|
+ if (url.pathname === "/auth/callback") {
|
|
|
+ const code = url.searchParams.get("code")
|
|
|
+ const state = url.searchParams.get("state")
|
|
|
+ const error = url.searchParams.get("error")
|
|
|
+ const errorDescription = url.searchParams.get("error_description")
|
|
|
+
|
|
|
+ if (error) {
|
|
|
+ const errorMsg = errorDescription || error
|
|
|
+ pendingOAuth?.reject(new Error(errorMsg))
|
|
|
+ pendingOAuth = undefined
|
|
|
+ return new Response(HTML_ERROR(errorMsg), {
|
|
|
+ headers: { "Content-Type": "text/html" },
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!code) {
|
|
|
+ const errorMsg = "Missing authorization code"
|
|
|
+ pendingOAuth?.reject(new Error(errorMsg))
|
|
|
+ pendingOAuth = undefined
|
|
|
+ return new Response(HTML_ERROR(errorMsg), {
|
|
|
+ status: 400,
|
|
|
+ headers: { "Content-Type": "text/html" },
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!pendingOAuth || state !== pendingOAuth.state) {
|
|
|
+ const errorMsg = "Invalid state - potential CSRF attack"
|
|
|
+ pendingOAuth?.reject(new Error(errorMsg))
|
|
|
+ pendingOAuth = undefined
|
|
|
+ return new Response(HTML_ERROR(errorMsg), {
|
|
|
+ status: 400,
|
|
|
+ headers: { "Content-Type": "text/html" },
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const current = pendingOAuth
|
|
|
+ pendingOAuth = undefined
|
|
|
+
|
|
|
+ exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
|
|
+ .then((tokens) => current.resolve(tokens))
|
|
|
+ .catch((err) => current.reject(err))
|
|
|
+
|
|
|
+ return new Response(HTML_SUCCESS, {
|
|
|
+ headers: { "Content-Type": "text/html" },
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ if (url.pathname === "/cancel") {
|
|
|
+ pendingOAuth?.reject(new Error("Login cancelled"))
|
|
|
+ pendingOAuth = undefined
|
|
|
+ return new Response("Login cancelled", { status: 200 })
|
|
|
+ }
|
|
|
+
|
|
|
+ return new Response("Not found", { status: 404 })
|
|
|
+ },
|
|
|
+ })
|
|
|
+
|
|
|
+ log.info("codex oauth server started", { port: OAUTH_PORT })
|
|
|
+ return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
|
|
+}
|
|
|
+
|
|
|
+function stopOAuthServer() {
|
|
|
+ if (oauthServer) {
|
|
|
+ oauthServer.stop()
|
|
|
+ oauthServer = undefined
|
|
|
+ log.info("codex oauth server stopped")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const timeout = setTimeout(
|
|
|
+ () => {
|
|
|
+ if (pendingOAuth) {
|
|
|
+ pendingOAuth = undefined
|
|
|
+ reject(new Error("OAuth callback timeout - authorization took too long"))
|
|
|
+ }
|
|
|
+ },
|
|
|
+ 5 * 60 * 1000,
|
|
|
+ ) // 5 minute timeout
|
|
|
+
|
|
|
+ pendingOAuth = {
|
|
|
+ pkce,
|
|
|
+ state,
|
|
|
+ resolve: (tokens) => {
|
|
|
+ clearTimeout(timeout)
|
|
|
+ resolve(tokens)
|
|
|
+ },
|
|
|
+ reject: (error) => {
|
|
|
+ clearTimeout(timeout)
|
|
|
+ reject(error)
|
|
|
+ },
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|
|
+ return {
|
|
|
+ auth: {
|
|
|
+ provider: "openai",
|
|
|
+ async loader(getAuth, provider) {
|
|
|
+ const auth = await getAuth()
|
|
|
+ if (auth.type !== "oauth") return {}
|
|
|
+
|
|
|
+ // Filter models to only allowed Codex models for OAuth
|
|
|
+ const allowedModels = new Set(["gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2", "gpt-5.2-codex"])
|
|
|
+ for (const modelId of Object.keys(provider.models)) {
|
|
|
+ if (!allowedModels.has(modelId)) {
|
|
|
+ delete provider.models[modelId]
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!provider.models["gpt-5.2-codex"]) {
|
|
|
+ provider.models["gpt-5.2-codex"] = {
|
|
|
+ id: "gpt-5.2-codex",
|
|
|
+ providerID: "openai",
|
|
|
+ api: {
|
|
|
+ id: "gpt-5.2-codex",
|
|
|
+ url: "https://chatgpt.com/backend-api/codex",
|
|
|
+ npm: "@ai-sdk/openai",
|
|
|
+ },
|
|
|
+ name: "GPT-5.2 Codex",
|
|
|
+ capabilities: {
|
|
|
+ temperature: false,
|
|
|
+ reasoning: true,
|
|
|
+ attachment: true,
|
|
|
+ toolcall: true,
|
|
|
+ input: { text: true, audio: false, image: true, video: false, pdf: false },
|
|
|
+ output: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
|
+ },
|
|
|
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
|
+ limit: { context: 400000, output: 128000 },
|
|
|
+ status: "active",
|
|
|
+ options: {},
|
|
|
+ headers: {},
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Zero out costs for Codex (included with ChatGPT subscription)
|
|
|
+ for (const model of Object.values(provider.models)) {
|
|
|
+ model.cost = {
|
|
|
+ input: 0,
|
|
|
+ output: 0,
|
|
|
+ cache: { read: 0, write: 0 },
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ apiKey: OAUTH_DUMMY_KEY,
|
|
|
+ async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
|
|
+ // Remove dummy API key authorization header
|
|
|
+ if (init?.headers) {
|
|
|
+ if (init.headers instanceof Headers) {
|
|
|
+ init.headers.delete("authorization")
|
|
|
+ init.headers.delete("Authorization")
|
|
|
+ } else if (Array.isArray(init.headers)) {
|
|
|
+ init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization")
|
|
|
+ } else {
|
|
|
+ delete init.headers["authorization"]
|
|
|
+ delete init.headers["Authorization"]
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const currentAuth = await getAuth()
|
|
|
+ if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
|
|
+
|
|
|
+ // Check if token needs refresh
|
|
|
+ if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
|
|
+ log.info("refreshing codex access token")
|
|
|
+ const tokens = await refreshAccessToken(currentAuth.refresh)
|
|
|
+ await input.client.auth.set({
|
|
|
+ path: { id: "codex" },
|
|
|
+ body: {
|
|
|
+ type: "oauth",
|
|
|
+ refresh: tokens.refresh_token,
|
|
|
+ access: tokens.access_token,
|
|
|
+ expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ currentAuth.access = tokens.access_token
|
|
|
+ }
|
|
|
+
|
|
|
+ // Build headers
|
|
|
+ const headers = new Headers()
|
|
|
+ if (init?.headers) {
|
|
|
+ if (init.headers instanceof Headers) {
|
|
|
+ init.headers.forEach((value, key) => headers.set(key, value))
|
|
|
+ } else if (Array.isArray(init.headers)) {
|
|
|
+ for (const [key, value] of init.headers) {
|
|
|
+ if (value !== undefined) headers.set(key, String(value))
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ for (const [key, value] of Object.entries(init.headers)) {
|
|
|
+ if (value !== undefined) headers.set(key, String(value))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Set authorization header with access token
|
|
|
+ headers.set("authorization", `Bearer ${currentAuth.access}`)
|
|
|
+
|
|
|
+ // Rewrite URL to Codex endpoint
|
|
|
+ let url: URL
|
|
|
+ if (typeof requestInput === "string") {
|
|
|
+ url = new URL(requestInput)
|
|
|
+ } else if (requestInput instanceof URL) {
|
|
|
+ url = requestInput
|
|
|
+ } else {
|
|
|
+ url = new URL(requestInput.url)
|
|
|
+ }
|
|
|
+
|
|
|
+ // If this is a messages/responses request, redirect to Codex endpoint
|
|
|
+ if (url.pathname.includes("/v1/responses") || url.pathname.includes("/chat/completions")) {
|
|
|
+ url = new URL(CODEX_API_ENDPOINT)
|
|
|
+ }
|
|
|
+
|
|
|
+ return fetch(url, {
|
|
|
+ ...init,
|
|
|
+ headers,
|
|
|
+ })
|
|
|
+ },
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: [
|
|
|
+ {
|
|
|
+ label: "ChatGPT Pro/Plus",
|
|
|
+ type: "oauth",
|
|
|
+ authorize: async () => {
|
|
|
+ const { redirectUri } = await startOAuthServer()
|
|
|
+ const pkce = await generatePKCE()
|
|
|
+ const state = generateState()
|
|
|
+ const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
|
|
|
+
|
|
|
+ const callbackPromise = waitForOAuthCallback(pkce, state)
|
|
|
+
|
|
|
+ return {
|
|
|
+ url: authUrl,
|
|
|
+ instructions: "Complete authorization in your browser. This window will close automatically.",
|
|
|
+ method: "auto" as const,
|
|
|
+ callback: async () => {
|
|
|
+ const tokens = await callbackPromise
|
|
|
+ stopOAuthServer()
|
|
|
+ return {
|
|
|
+ type: "success" as const,
|
|
|
+ refresh: tokens.refresh_token,
|
|
|
+ access: tokens.access_token,
|
|
|
+ expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
|
|
+ }
|
|
|
+ },
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|