Przeglądaj źródła

feat: codex auth support (#7537)

Aiden Cline 1 miesiąc temu
rodzic
commit
172bbdaced

+ 2 - 0
packages/opencode/src/auth/index.ts

@@ -3,6 +3,8 @@ import { Global } from "../global"
 import fs from "fs/promises"
 import fs from "fs/promises"
 import z from "zod"
 import z from "zod"
 
 
+export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
+
 export namespace Auth {
 export namespace Auth {
   export const Oauth = z
   export const Oauth = z
     .object({
     .object({

+ 417 - 0
packages/opencode/src/plugin/codex.ts

@@ -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,
+                }
+              },
+            }
+          },
+        },
+      ],
+    },
+  }
+}

+ 17 - 1
packages/opencode/src/plugin/index.ts

@@ -7,12 +7,16 @@ import { Server } from "../server/server"
 import { BunProc } from "../bun"
 import { BunProc } from "../bun"
 import { Instance } from "../project/instance"
 import { Instance } from "../project/instance"
 import { Flag } from "../flag/flag"
 import { Flag } from "../flag/flag"
+import { CodexAuthPlugin } from "./codex"
 
 
 export namespace Plugin {
 export namespace Plugin {
   const log = Log.create({ service: "plugin" })
   const log = Log.create({ service: "plugin" })
 
 
   const BUILTIN = ["[email protected]", "[email protected]"]
   const BUILTIN = ["[email protected]", "[email protected]"]
 
 
+  // Built-in plugins that are directly imported (not installed from npm)
+  const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
+
   const state = Instance.state(async () => {
   const state = Instance.state(async () => {
     const client = createOpencodeClient({
     const client = createOpencodeClient({
       baseUrl: "http://localhost:4096",
       baseUrl: "http://localhost:4096",
@@ -20,7 +24,7 @@ export namespace Plugin {
       fetch: async (...args) => Server.App().fetch(...args),
       fetch: async (...args) => Server.App().fetch(...args),
     })
     })
     const config = await Config.get()
     const config = await Config.get()
-    const hooks = []
+    const hooks: Hooks[] = []
     const input: PluginInput = {
     const input: PluginInput = {
       client,
       client,
       project: Instance.project,
       project: Instance.project,
@@ -29,11 +33,23 @@ export namespace Plugin {
       serverUrl: Server.url(),
       serverUrl: Server.url(),
       $: Bun.$,
       $: Bun.$,
     }
     }
+
+    // Load internal plugins first
+    if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
+      for (const plugin of INTERNAL_PLUGINS) {
+        log.info("loading internal plugin", { name: plugin.name })
+        const init = await plugin(input)
+        hooks.push(init)
+      }
+    }
+
     const plugins = [...(config.plugin ?? [])]
     const plugins = [...(config.plugin ?? [])]
     if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
     if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
       plugins.push(...BUILTIN)
       plugins.push(...BUILTIN)
     }
     }
     for (let plugin of plugins) {
     for (let plugin of plugins) {
+      // ignore old codex plugin since it is supported first party now
+      if (plugin.includes("opencode-openai-codex-auth")) continue
       log.info("loading plugin", { path: plugin })
       log.info("loading plugin", { path: plugin })
       if (!plugin.startsWith("file://")) {
       if (!plugin.startsWith("file://")) {
         const lastAtIndex = plugin.lastIndexOf("@")
         const lastAtIndex = plugin.lastIndexOf("@")

+ 1 - 0
packages/opencode/src/server/server.ts

@@ -74,6 +74,7 @@ export namespace Server {
   const app = new Hono()
   const app = new Hono()
   export const App: () => Hono = lazy(
   export const App: () => Hono = lazy(
     () =>
     () =>
+      // TODO: Break server.ts into smaller route files to fix type inference
       app
       app
         .onError((err, c) => {
         .onError((err, c) => {
           log.error("failed", {
           log.error("failed", {

+ 44 - 17
packages/opencode/src/session/llm.ts

@@ -1,3 +1,5 @@
+import os from "os"
+import { Installation } from "@/installation"
 import { Provider } from "@/provider/provider"
 import { Provider } from "@/provider/provider"
 import { Log } from "@/util/log"
 import { Log } from "@/util/log"
 import {
 import {
@@ -19,6 +21,7 @@ import { Plugin } from "@/plugin"
 import { SystemPrompt } from "./system"
 import { SystemPrompt } from "./system"
 import { Flag } from "@/flag/flag"
 import { Flag } from "@/flag/flag"
 import { PermissionNext } from "@/permission/next"
 import { PermissionNext } from "@/permission/next"
+import { Auth } from "@/auth"
 
 
 export namespace LLM {
 export namespace LLM {
   const log = Log.create({ service: "llm" })
   const log = Log.create({ service: "llm" })
@@ -82,12 +85,24 @@ export namespace LLM {
     }
     }
 
 
     const provider = await Provider.getProvider(input.model.providerID)
     const provider = await Provider.getProvider(input.model.providerID)
+    const auth = await Auth.get(input.model.providerID)
+    const isCodex = provider.id === "openai" && auth?.type === "oauth"
+
     const variant =
     const variant =
       !input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
       !input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
     const base = input.small
     const base = input.small
       ? ProviderTransform.smallOptions(input.model)
       ? ProviderTransform.smallOptions(input.model)
       : ProviderTransform.options(input.model, input.sessionID, provider.options)
       : ProviderTransform.options(input.model, input.sessionID, provider.options)
-    const options = pipe(base, mergeDeep(input.model.options), mergeDeep(input.agent.options), mergeDeep(variant))
+    const options: Record<string, any> = pipe(
+      base,
+      mergeDeep(input.model.options),
+      mergeDeep(input.agent.options),
+      mergeDeep(variant),
+    )
+    if (isCodex) {
+      options.instructions = SystemPrompt.instructions()
+      options.store = false
+    }
 
 
     const params = await Plugin.trigger(
     const params = await Plugin.trigger(
       "chat.params",
       "chat.params",
@@ -108,16 +123,14 @@ export namespace LLM {
       },
       },
     )
     )
 
 
-    l.info("params", {
-      params,
-    })
-
-    const maxOutputTokens = ProviderTransform.maxOutputTokens(
-      input.model.api.npm,
-      params.options,
-      input.model.limit.output,
-      OUTPUT_TOKEN_MAX,
-    )
+    const maxOutputTokens = isCodex
+      ? undefined
+      : ProviderTransform.maxOutputTokens(
+          input.model.api.npm,
+          params.options,
+          input.model.limit.output,
+          OUTPUT_TOKEN_MAX,
+        )
 
 
     const tools = await resolveTools(input)
     const tools = await resolveTools(input)
 
 
@@ -157,6 +170,13 @@ export namespace LLM {
       maxOutputTokens,
       maxOutputTokens,
       abortSignal: input.abort,
       abortSignal: input.abort,
       headers: {
       headers: {
+        ...(isCodex
+          ? {
+              originator: "opencode",
+              "User-Agent": `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
+              session_id: input.sessionID,
+            }
+          : undefined),
         ...(input.model.providerID.startsWith("opencode")
         ...(input.model.providerID.startsWith("opencode")
           ? {
           ? {
               "x-opencode-project": Instance.project.id,
               "x-opencode-project": Instance.project.id,
@@ -169,12 +189,19 @@ export namespace LLM {
       },
       },
       maxRetries: input.retries ?? 0,
       maxRetries: input.retries ?? 0,
       messages: [
       messages: [
-        ...system.map(
-          (x): ModelMessage => ({
-            role: "system",
-            content: x,
-          }),
-        ),
+        ...(isCodex
+          ? [
+              {
+                role: "user",
+                content: system.join("\n\n"),
+              } as ModelMessage,
+            ]
+          : system.map(
+              (x): ModelMessage => ({
+                role: "system",
+                content: x,
+              }),
+            )),
         ...input.messages,
         ...input.messages,
       ],
       ],
       model: wrapLanguageModel({
       model: wrapLanguageModel({

+ 1 - 0
packages/opencode/src/session/prompt/codex_header.txt

@@ -0,0 +1 @@
+You are a coding agent running in the opencode, a terminal-based coding assistant. opencode is an open source project. You are expected to be precise, safe, and helpful.

+ 5 - 0
packages/opencode/src/session/system.ts

@@ -14,6 +14,7 @@ import PROMPT_GEMINI from "./prompt/gemini.txt"
 import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
 import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
 
 
 import PROMPT_CODEX from "./prompt/codex.txt"
 import PROMPT_CODEX from "./prompt/codex.txt"
+import PROMPT_CODEX_INSTRUCTIONS from "./prompt/codex_header.txt"
 import type { Provider } from "@/provider/provider"
 import type { Provider } from "@/provider/provider"
 import { Flag } from "@/flag/flag"
 import { Flag } from "@/flag/flag"
 
 
@@ -23,6 +24,10 @@ export namespace SystemPrompt {
     return []
     return []
   }
   }
 
 
+  export function instructions() {
+    return PROMPT_CODEX_INSTRUCTIONS.trim()
+  }
+
   export function provider(model: Provider.Model) {
   export function provider(model: Provider.Model) {
     if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
     if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
     if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
     if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))