From 26de7807cd601a430afd5e786778fb191d8588b1 Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 00:28:11 -0400 Subject: [PATCH 01/14] feat: add OpenAI Codex OAuth plugin (layer 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements built-in OAuth authentication for ChatGPT Plus/Pro subscribers to use OpenAI models via their existing subscription. Features: - PKCE OAuth flow with OpenAI's auth endpoints - Local callback server on port 1455 - Cross-platform browser launching - Automatic token refresh - Integrated as default plugin in opencode Based on: https://github.com/numman-ali/opencode-openai-codex-auth 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- bun.lock | 26 +++ .../assets/oauth-success.html | 122 ++++++++++++ packages/openai-codex-auth/package.json | 30 +++ packages/openai-codex-auth/src/auth/auth.ts | 178 ++++++++++++++++++ .../openai-codex-auth/src/auth/browser.ts | 45 +++++ packages/openai-codex-auth/src/auth/server.ts | 152 +++++++++++++++ packages/openai-codex-auth/src/constants.ts | 35 ++++ packages/openai-codex-auth/src/index.ts | 106 +++++++++++ .../src/request/fetch-helpers.ts | 82 ++++++++ packages/openai-codex-auth/src/types.ts | 52 +++++ packages/openai-codex-auth/tsconfig.json | 12 ++ packages/opencode/package.json | 1 + packages/opencode/src/plugin/index.ts | 12 ++ 13 files changed, 853 insertions(+) create mode 100644 packages/openai-codex-auth/assets/oauth-success.html create mode 100644 packages/openai-codex-auth/package.json create mode 100644 packages/openai-codex-auth/src/auth/auth.ts create mode 100644 packages/openai-codex-auth/src/auth/browser.ts create mode 100644 packages/openai-codex-auth/src/auth/server.ts create mode 100644 packages/openai-codex-auth/src/constants.ts create mode 100644 packages/openai-codex-auth/src/index.ts create mode 100644 packages/openai-codex-auth/src/request/fetch-helpers.ts create mode 100644 packages/openai-codex-auth/src/types.ts create mode 100644 packages/openai-codex-auth/tsconfig.json diff --git a/bun.lock b/bun.lock index b6318af40ed..67d5721360b 100644 --- a/bun.lock +++ b/bun.lock @@ -243,6 +243,23 @@ "typescript": "catalog:", }, }, + "packages/openai-codex-auth": { + "name": "@opencode-ai/openai-codex-auth", + "version": "1.0.0", + "dependencies": { + "@openauthjs/openauth": "^0.4.3", + "hono": "^4.10.4", + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + "peerDependencies": { + "@opencode-ai/plugin": "workspace:*", + }, + }, "packages/opencode": { "name": "opencode", "version": "1.0.209", @@ -279,6 +296,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/openai-codex-auth": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -1175,6 +1193,8 @@ "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], + "@opencode-ai/openai-codex-auth": ["@opencode-ai/openai-codex-auth@workspace:packages/openai-codex-auth"], + "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], @@ -4119,6 +4139,8 @@ "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + "@opencode-ai/openai-codex-auth/@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="], + "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="], "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], @@ -4709,6 +4731,10 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + "@opencode-ai/openai-codex-auth/@openauthjs/openauth/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="], + + "@opencode-ai/openai-codex-auth/@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AG8vnSi1W2pbgR2B911EfGqtLE9c4hQBYkv/x7Z+Kt0VxhgQKcW7UNDVYsu9YxwV6u+OJrvdJrMq6DNWoBjihQ=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="], diff --git a/packages/openai-codex-auth/assets/oauth-success.html b/packages/openai-codex-auth/assets/oauth-success.html new file mode 100644 index 00000000000..02144de8376 --- /dev/null +++ b/packages/openai-codex-auth/assets/oauth-success.html @@ -0,0 +1,122 @@ + + + + + + Authentication Successful - OpenCode + + + +
+
+ + + +
+ +

Authentication Successful!

+ +

+ You've successfully connected your ChatGPT account to OpenCode. + You can now use OpenAI models with your subscription. +

+ +
+ You can close this window and return to your terminal. +
+ +
+ Powered by OpenCode +
+
+ + + + diff --git a/packages/openai-codex-auth/package.json b/packages/openai-codex-auth/package.json new file mode 100644 index 00000000000..d0f9c299fb6 --- /dev/null +++ b/packages/openai-codex-auth/package.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/openai-codex-auth", + "version": "1.0.0", + "type": "module", + "scripts": { + "typecheck": "tsgo --noEmit", + "build": "tsc" + }, + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "dist", + "assets" + ], + "dependencies": { + "@openauthjs/openauth": "^0.4.3", + "hono": "^4.10.4" + }, + "peerDependencies": { + "@opencode-ai/plugin": "workspace:*" + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/node": "catalog:", + "typescript": "catalog:", + "@typescript/native-preview": "catalog:" + } +} diff --git a/packages/openai-codex-auth/src/auth/auth.ts b/packages/openai-codex-auth/src/auth/auth.ts new file mode 100644 index 00000000000..d452c5b3eea --- /dev/null +++ b/packages/openai-codex-auth/src/auth/auth.ts @@ -0,0 +1,178 @@ +/** + * Core OAuth implementation with PKCE + */ + +import { randomBytes, createHash } from "crypto" +import { OAUTH_CONFIG } from "../constants" +import type { PKCEChallenge, OAuthTokenResponse, OAuthError, AuthCallbackResult } from "../types" +import { openBrowser } from "./browser" +import { startCallbackServer } from "./server" + +/** + * Generate a cryptographically secure random string + */ +function generateRandomString(length: number): string { + return randomBytes(length).toString("base64url").slice(0, length) +} + +/** + * Create a SHA256 hash and return as base64url + */ +function sha256(input: string): string { + return createHash("sha256").update(input).digest("base64url") +} + +/** + * Generate PKCE challenge (code_verifier and code_challenge) + */ +export function createPKCEChallenge(): PKCEChallenge { + // Generate a random 43-128 character code verifier + const codeVerifier = generateRandomString(64) + // Create the code challenge using S256 method + const codeChallenge = sha256(codeVerifier) + // Generate state for CSRF protection + const state = generateRandomString(32) + + return { + codeVerifier, + codeChallenge, + state, + } +} + +/** + * Build the authorization URL with PKCE parameters + */ +export function buildAuthorizationUrl(pkce: PKCEChallenge): string { + const params = new URLSearchParams({ + client_id: OAUTH_CONFIG.clientId, + redirect_uri: OAUTH_CONFIG.redirectUri, + response_type: "code", + scope: OAUTH_CONFIG.scopes.join(" "), + state: pkce.state, + code_challenge: pkce.codeChallenge, + code_challenge_method: "S256", + audience: OAUTH_CONFIG.audience, + }) + + return `${OAUTH_CONFIG.authorizationUrl}?${params.toString()}` +} + +/** + * Exchange authorization code for tokens + */ +export async function exchangeCodeForTokens( + code: string, + codeVerifier: string +): Promise { + const response = await fetch(OAUTH_CONFIG.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: OAUTH_CONFIG.clientId, + code, + redirect_uri: OAUTH_CONFIG.redirectUri, + code_verifier: codeVerifier, + }), + }) + + if (!response.ok) { + const error = (await response.json()) as OAuthError + throw new Error(`Token exchange failed: ${error.error_description || error.error}`) + } + + return response.json() as Promise +} + +/** + * Refresh an access token using the refresh token + */ +export async function refreshAccessToken(refreshToken: string): Promise { + const response = await fetch(OAUTH_CONFIG.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: OAUTH_CONFIG.clientId, + refresh_token: refreshToken, + }), + }) + + if (!response.ok) { + const error = (await response.json()) as OAuthError + throw new Error(`Token refresh failed: ${error.error_description || error.error}`) + } + + return response.json() as Promise +} + +/** + * Decode a JWT token to extract claims (without verification) + */ +export function decodeJWT(token: string): Record { + try { + const parts = token.split(".") + if (parts.length !== 3) { + return {} + } + const payload = Buffer.from(parts[1], "base64url").toString("utf-8") + return JSON.parse(payload) + } catch { + return {} + } +} + +/** + * Perform the complete OAuth flow + * Returns authorization URL and callback handler + */ +export async function initiateOAuthFlow(): Promise<{ + url: string + instructions: string + method: "auto" + callback: () => Promise +}> { + const pkce = createPKCEChallenge() + const authUrl = buildAuthorizationUrl(pkce) + + // Start the callback server before returning + const callbackPromise = startCallbackServer(pkce.state) + + return { + url: authUrl, + instructions: "Complete the authentication in your browser. You will be redirected back automatically.", + method: "auto" as const, + callback: async (): Promise => { + try { + // Open browser + await openBrowser(authUrl) + + // Wait for callback + const result = await callbackPromise + + // Exchange code for tokens + const tokens = await exchangeCodeForTokens(result.code, pkce.codeVerifier) + + // Calculate expiry time + const expiresAt = Date.now() + tokens.expires_in * 1000 + + return { + type: "success", + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: expiresAt, + } + } catch (error) { + return { + type: "failed", + error: error instanceof Error ? error.message : "Unknown error", + } + } + }, + } +} diff --git a/packages/openai-codex-auth/src/auth/browser.ts b/packages/openai-codex-auth/src/auth/browser.ts new file mode 100644 index 00000000000..b3e072b95a9 --- /dev/null +++ b/packages/openai-codex-auth/src/auth/browser.ts @@ -0,0 +1,45 @@ +/** + * Cross-platform browser launching utility + */ + +import { spawn } from "child_process" + +/** + * Opens a URL in the default system browser + */ +export async function openBrowser(url: string): Promise { + const platform = process.platform + + let command: string + let args: string[] + + switch (platform) { + case "darwin": + command = "open" + args = [url] + break + case "win32": + command = "cmd" + args = ["/c", "start", "", url] + break + default: + // Linux and others + command = "xdg-open" + args = [url] + break + } + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + }) + + child.on("error", (err) => { + reject(new Error(`Failed to open browser: ${err.message}`)) + }) + + child.unref() + resolve() + }) +} diff --git a/packages/openai-codex-auth/src/auth/server.ts b/packages/openai-codex-auth/src/auth/server.ts new file mode 100644 index 00000000000..6f682675a0d --- /dev/null +++ b/packages/openai-codex-auth/src/auth/server.ts @@ -0,0 +1,152 @@ +/** + * Local OAuth callback server + * Listens on localhost for the OAuth redirect + */ + +import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http" +import { LOCAL_SERVER } from "../constants" +import type { AuthorizationResult } from "../types" +import { readFileSync } from "fs" +import { join, dirname } from "path" +import { fileURLToPath } from "url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +/** + * Starts a local HTTP server to receive the OAuth callback + * Returns a promise that resolves with the authorization code + */ +export function startCallbackServer(expectedState: string): Promise { + return new Promise((resolve, reject) => { + let server: Server | null = null + let timeoutId: ReturnType | null = null + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + if (server) { + server.close() + server = null + } + } + + // 60 second timeout + timeoutId = setTimeout(() => { + cleanup() + reject(new Error("OAuth callback timeout - no response received within 60 seconds")) + }, 60000) + + server = createServer((req: IncomingMessage, res: ServerResponse) => { + const url = new URL(req.url || "", `http://${LOCAL_SERVER.host}:${LOCAL_SERVER.port}`) + + if (url.pathname === LOCAL_SERVER.callbackPath) { + 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) { + // Serve error page + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(` + + + Authentication Failed + +
+

Authentication Failed

+

${errorDescription || error}

+

You can close this window.

+
+ + + `) + cleanup() + reject(new Error(`OAuth error: ${errorDescription || error}`)) + return + } + + if (!code || !state) { + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(` + + + Invalid Request + +
+

Invalid Request

+

Missing authorization code or state parameter.

+
+ + + `) + cleanup() + reject(new Error("Invalid OAuth callback - missing code or state")) + return + } + + if (state !== expectedState) { + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(` + + + Security Error + +
+

Security Error

+

State parameter mismatch. This could be a CSRF attack.

+
+ + + `) + cleanup() + reject(new Error("OAuth state mismatch - possible CSRF attack")) + return + } + + // Success! Serve success page + let successHtml: string + try { + successHtml = readFileSync(join(__dirname, "../../assets/oauth-success.html"), "utf-8") + } catch { + successHtml = ` + + + Authentication Successful + +
+

Authentication Successful!

+

You can close this window and return to the terminal.

+
+ + + ` + } + + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(successHtml) + + cleanup() + resolve({ code, state }) + } else { + res.writeHead(404) + res.end("Not found") + } + }) + + server.on("error", (err: NodeJS.ErrnoException) => { + cleanup() + if (err.code === "EADDRINUSE") { + reject(new Error(`Port ${LOCAL_SERVER.port} is already in use. Please stop any other OAuth servers.`)) + } else { + reject(err) + } + }) + + server.listen(LOCAL_SERVER.port, LOCAL_SERVER.host, () => { + // Server is ready + }) + }) +} diff --git a/packages/openai-codex-auth/src/constants.ts b/packages/openai-codex-auth/src/constants.ts new file mode 100644 index 00000000000..8154e8edab9 --- /dev/null +++ b/packages/openai-codex-auth/src/constants.ts @@ -0,0 +1,35 @@ +/** + * OpenAI OAuth Configuration Constants + * These are the official OAuth credentials used by the Codex CLI + */ + +export const OAUTH_CONFIG = { + clientId: "app_EMoamEEZ73f0CkXaXp7hrann", + authorizationUrl: "https://auth.openai.com/oauth/authorize", + tokenUrl: "https://auth.openai.com/oauth/token", + redirectUri: "http://localhost:1455/auth/callback", + scopes: ["openid", "profile", "email", "offline_access"], + audience: "https://api.openai.com/v1", +} as const + +export const LOCAL_SERVER = { + host: "127.0.0.1", + port: 1455, + callbackPath: "/auth/callback", +} as const + +export const CODEX_API = { + baseUrl: "https://api.openai.com/v1", + responsesEndpoint: "/responses", +} as const + +export const PLUGIN_NAME = "openai-codex-auth" +export const PROVIDER_ID = "openai" +export const AUTH_LABEL = "ChatGPT Plus/Pro (Codex Subscription)" + +/** + * Timeout for OAuth polling (in milliseconds) + * 60 seconds = 600 iterations * 100ms + */ +export const OAUTH_TIMEOUT_MS = 60000 +export const OAUTH_POLL_INTERVAL_MS = 100 diff --git a/packages/openai-codex-auth/src/index.ts b/packages/openai-codex-auth/src/index.ts new file mode 100644 index 00000000000..13e59eec2fa --- /dev/null +++ b/packages/openai-codex-auth/src/index.ts @@ -0,0 +1,106 @@ +/** + * OpenAI Codex Auth Plugin for OpenCode + * + * Enables ChatGPT Plus/Pro subscribers to use OpenAI models via OAuth + * Uses the same authentication flow as the official Codex CLI + */ + +import type { Plugin, AuthHook, AuthOuathResult } from "@opencode-ai/plugin" +import { initiateOAuthFlow, refreshAccessToken } from "./auth/auth" +import { PROVIDER_ID, AUTH_LABEL } from "./constants" +import { isTokenExpired } from "./request/fetch-helpers" + +/** + * OpenAI Codex Authentication Plugin + * + * Provides OAuth authentication for OpenAI API access using + * ChatGPT Plus/Pro subscription credentials. + */ +export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { + const authHook: AuthHook = { + provider: PROVIDER_ID, + + /** + * Loader function called when the provider needs authentication + * Handles token refresh and returns SDK options + */ + loader: async (getAuth, _provider) => { + const auth = await getAuth() + + if (auth.type !== "oauth") { + return {} + } + + // Check if token needs refresh + if (isTokenExpired(auth.expires)) { + try { + const tokens = await refreshAccessToken(auth.refresh) + const newExpires = Date.now() + tokens.expires_in * 1000 + + // Return refreshed credentials + // Note: The opencode system will handle persisting these + return { + apiKey: tokens.access_token, + _refreshedAuth: { + type: "oauth" as const, + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: newExpires, + }, + } + } catch (error) { + console.error("[openai-codex-auth] Failed to refresh token:", error) + // Return existing token and let the API call fail if it's truly expired + return { + apiKey: auth.access, + } + } + } + + return { + apiKey: auth.access, + } + }, + + methods: [ + { + type: "oauth", + label: AUTH_LABEL, + + /** + * Initiate the OAuth flow + */ + authorize: async (_inputs?: Record): Promise => { + const flow = await initiateOAuthFlow() + + return { + url: flow.url, + instructions: flow.instructions, + method: "auto" as const, + callback: async () => { + const result = await flow.callback() + + if (result.type === "failed") { + return { type: "failed" as const } + } + + return { + type: "success" as const, + refresh: result.refresh, + access: result.access, + expires: result.expires, + } + }, + } + }, + }, + ], + } + + return { + auth: authHook, + } +} + +// Default export for compatibility +export default OpenAICodexAuthPlugin diff --git a/packages/openai-codex-auth/src/request/fetch-helpers.ts b/packages/openai-codex-auth/src/request/fetch-helpers.ts new file mode 100644 index 00000000000..62fa12f2fb3 --- /dev/null +++ b/packages/openai-codex-auth/src/request/fetch-helpers.ts @@ -0,0 +1,82 @@ +/** + * Fetch helpers for token management and API requests + */ + +import { refreshAccessToken } from "../auth/auth" +import { CODEX_API } from "../constants" + +export interface TokenManager { + accessToken: string + refreshToken: string + expiresAt: number + onTokenRefresh?: (tokens: { access: string; refresh: string; expires: number }) => void +} + +/** + * Check if the access token is expired or about to expire + * Considers token expired if it expires within 5 minutes + */ +export function isTokenExpired(expiresAt: number): boolean { + const bufferMs = 5 * 60 * 1000 // 5 minutes + return Date.now() >= expiresAt - bufferMs +} + +/** + * Get a valid access token, refreshing if necessary + */ +export async function getValidAccessToken(manager: TokenManager): Promise { + if (!isTokenExpired(manager.expiresAt)) { + return manager.accessToken + } + + // Token is expired, refresh it + const tokens = await refreshAccessToken(manager.refreshToken) + const newExpiresAt = Date.now() + tokens.expires_in * 1000 + + // Update manager + manager.accessToken = tokens.access_token + manager.refreshToken = tokens.refresh_token + manager.expiresAt = newExpiresAt + + // Notify callback if provided + if (manager.onTokenRefresh) { + manager.onTokenRefresh({ + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: newExpiresAt, + }) + } + + return tokens.access_token +} + +/** + * Create authorization headers for API requests + */ +export function createAuthHeaders(accessToken: string): Record { + return { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + } +} + +/** + * Make an authenticated API request to OpenAI + */ +export async function authenticatedFetch( + manager: TokenManager, + endpoint: string, + options: RequestInit = {} +): Promise { + const accessToken = await getValidAccessToken(manager) + + const url = endpoint.startsWith("http") ? endpoint : `${CODEX_API.baseUrl}${endpoint}` + + return fetch(url, { + ...options, + headers: { + ...createAuthHeaders(accessToken), + ...options.headers, + }, + }) +} diff --git a/packages/openai-codex-auth/src/types.ts b/packages/openai-codex-auth/src/types.ts new file mode 100644 index 00000000000..8a519a9d483 --- /dev/null +++ b/packages/openai-codex-auth/src/types.ts @@ -0,0 +1,52 @@ +/** + * TypeScript type definitions for OpenAI Codex Auth Plugin + */ + +export interface OAuthTokenResponse { + access_token: string + refresh_token: string + token_type: string + expires_in: number + scope: string + id_token?: string +} + +export interface OAuthError { + error: string + error_description?: string +} + +export interface PKCEChallenge { + codeVerifier: string + codeChallenge: string + state: string +} + +export interface AuthorizationResult { + code: string + state: string +} + +export interface TokenInfo { + accessToken: string + refreshToken: string + expiresAt: number +} + +export interface DecodedJWT { + sub?: string + email?: string + name?: string + exp?: number + iat?: number +} + +export type AuthCallbackResult = { + type: "success" + refresh: string + access: string + expires: number +} | { + type: "failed" + error?: string +} diff --git a/packages/openai-codex-auth/tsconfig.json b/packages/openai-codex-auth/tsconfig.json new file mode 100644 index 00000000000..58072c81c96 --- /dev/null +++ b/packages/openai-codex-auth/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "module": "preserve", + "declaration": true, + "moduleResolution": "bundler", + "lib": ["es2022", "dom", "dom.iterable"] + }, + "include": ["src"] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e9650145a3a..eeb2046b970 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -75,6 +75,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/openai-codex-auth": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 79f6094944a..884ec9d07ea 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -7,6 +7,7 @@ import { Server } from "../server/server" import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" +import { OpenAICodexAuthPlugin } from "@opencode-ai/openai-codex-auth" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -47,6 +48,17 @@ export namespace Plugin { } } + // Load built-in plugins + if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { + try { + log.info("loading built-in plugin", { name: "openai-codex-auth" }) + const openaiCodexHooks = await OpenAICodexAuthPlugin(input) + hooks.push(openaiCodexHooks) + } catch (err) { + log.error("failed to load openai-codex-auth plugin", { error: err }) + } + } + return { hooks, input, From f007def23a050ff1d86715448485a3e54d918978 Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 00:32:26 -0400 Subject: [PATCH 02/14] fix: bind OAuth server to 0.0.0.0 for WSL2 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/openai-codex-auth/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openai-codex-auth/src/constants.ts b/packages/openai-codex-auth/src/constants.ts index 8154e8edab9..7f38505ea47 100644 --- a/packages/openai-codex-auth/src/constants.ts +++ b/packages/openai-codex-auth/src/constants.ts @@ -13,7 +13,7 @@ export const OAUTH_CONFIG = { } as const export const LOCAL_SERVER = { - host: "127.0.0.1", + host: "0.0.0.0", // Bind to all interfaces for WSL2 compatibility port: 1455, callbackPath: "/auth/callback", } as const From 9431776ef094e5c66084f5fa66392cca4f6258d1 Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 00:39:55 -0400 Subject: [PATCH 03/14] fix(openai-codex-auth): add API scopes for responses endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add api.responses.read and api.responses.write scopes to OAuth config to fix "Missing scopes: api.responses.write" error when using OpenAI API. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/openai-codex-auth/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openai-codex-auth/src/constants.ts b/packages/openai-codex-auth/src/constants.ts index 7f38505ea47..fc29d6408bb 100644 --- a/packages/openai-codex-auth/src/constants.ts +++ b/packages/openai-codex-auth/src/constants.ts @@ -8,7 +8,7 @@ export const OAUTH_CONFIG = { authorizationUrl: "https://auth.openai.com/oauth/authorize", tokenUrl: "https://auth.openai.com/oauth/token", redirectUri: "http://localhost:1455/auth/callback", - scopes: ["openid", "profile", "email", "offline_access"], + scopes: ["openid", "profile", "email", "offline_access", "api.responses.read", "api.responses.write"], audience: "https://api.openai.com/v1", } as const From 08ddff8937f032c0adcc797841fe42c64b44b4de Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 01:21:23 -0400 Subject: [PATCH 04/14] fix(openai-codex-auth): revert to standard OAuth scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove api.responses.read/write scopes as they are not permitted by the Codex OAuth client. API access is implicit with the token. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/openai-codex-auth/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openai-codex-auth/src/constants.ts b/packages/openai-codex-auth/src/constants.ts index fc29d6408bb..7f38505ea47 100644 --- a/packages/openai-codex-auth/src/constants.ts +++ b/packages/openai-codex-auth/src/constants.ts @@ -8,7 +8,7 @@ export const OAUTH_CONFIG = { authorizationUrl: "https://auth.openai.com/oauth/authorize", tokenUrl: "https://auth.openai.com/oauth/token", redirectUri: "http://localhost:1455/auth/callback", - scopes: ["openid", "profile", "email", "offline_access", "api.responses.read", "api.responses.write"], + scopes: ["openid", "profile", "email", "offline_access"], audience: "https://api.openai.com/v1", } as const From e512033862406d7934af9da83f922282ee7694b3 Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 01:47:00 -0400 Subject: [PATCH 05/14] fix(openai-codex-auth): use ChatGPT Codex backend instead of OpenAI API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth token works with chatgpt.com/backend-api, not api.openai.com. - Change base URL to https://chatgpt.com/backend-api - Use /codex/responses endpoint - Add required headers: OpenAI-Beta, originator, chatgpt-account-id - Extract account ID from JWT token claims 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/openai-codex-auth/src/constants.ts | 10 +++- packages/openai-codex-auth/src/index.ts | 52 ++++++++++++------- .../src/request/fetch-helpers.ts | 40 ++++++++++++-- 3 files changed, 77 insertions(+), 25 deletions(-) diff --git a/packages/openai-codex-auth/src/constants.ts b/packages/openai-codex-auth/src/constants.ts index 7f38505ea47..abeffb6c1f3 100644 --- a/packages/openai-codex-auth/src/constants.ts +++ b/packages/openai-codex-auth/src/constants.ts @@ -19,8 +19,14 @@ export const LOCAL_SERVER = { } as const export const CODEX_API = { - baseUrl: "https://api.openai.com/v1", - responsesEndpoint: "/responses", + baseUrl: "https://chatgpt.com/backend-api", + responsesEndpoint: "/codex/responses", +} as const + +export const CODEX_HEADERS = { + openAiBeta: "responses=experimental", + originator: "codex_cli_rs", + jwtClaimPath: "https://api.openai.com/auth", } as const export const PLUGIN_NAME = "openai-codex-auth" diff --git a/packages/openai-codex-auth/src/index.ts b/packages/openai-codex-auth/src/index.ts index 13e59eec2fa..5b2ab333b4c 100644 --- a/packages/openai-codex-auth/src/index.ts +++ b/packages/openai-codex-auth/src/index.ts @@ -7,8 +7,8 @@ import type { Plugin, AuthHook, AuthOuathResult } from "@opencode-ai/plugin" import { initiateOAuthFlow, refreshAccessToken } from "./auth/auth" -import { PROVIDER_ID, AUTH_LABEL } from "./constants" -import { isTokenExpired } from "./request/fetch-helpers" +import { PROVIDER_ID, AUTH_LABEL, CODEX_API, CODEX_HEADERS } from "./constants" +import { isTokenExpired, extractAccountId } from "./request/fetch-helpers" /** * OpenAI Codex Authentication Plugin @@ -22,7 +22,7 @@ export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { /** * Loader function called when the provider needs authentication - * Handles token refresh and returns SDK options + * Handles token refresh and returns SDK options for Codex backend */ loader: async (getAuth, _provider) => { const auth = await getAuth() @@ -31,35 +31,47 @@ export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { return {} } + /** + * Build SDK options with Codex backend configuration + */ + const buildOptions = (accessToken: string, refreshedAuth?: object) => { + const accountId = extractAccountId(accessToken) + const headers: Record = { + "OpenAI-Beta": CODEX_HEADERS.openAiBeta, + originator: CODEX_HEADERS.originator, + } + if (accountId) { + headers["chatgpt-account-id"] = accountId + } + + return { + apiKey: accessToken, + baseURL: CODEX_API.baseUrl, + headers, + ...(refreshedAuth ? { _refreshedAuth: refreshedAuth } : {}), + } + } + // Check if token needs refresh if (isTokenExpired(auth.expires)) { try { const tokens = await refreshAccessToken(auth.refresh) const newExpires = Date.now() + tokens.expires_in * 1000 - // Return refreshed credentials - // Note: The opencode system will handle persisting these - return { - apiKey: tokens.access_token, - _refreshedAuth: { - type: "oauth" as const, - access: tokens.access_token, - refresh: tokens.refresh_token, - expires: newExpires, - }, - } + return buildOptions(tokens.access_token, { + type: "oauth" as const, + access: tokens.access_token, + refresh: tokens.refresh_token, + expires: newExpires, + }) } catch (error) { console.error("[openai-codex-auth] Failed to refresh token:", error) // Return existing token and let the API call fail if it's truly expired - return { - apiKey: auth.access, - } + return buildOptions(auth.access) } } - return { - apiKey: auth.access, - } + return buildOptions(auth.access) }, methods: [ diff --git a/packages/openai-codex-auth/src/request/fetch-helpers.ts b/packages/openai-codex-auth/src/request/fetch-helpers.ts index 62fa12f2fb3..266845d01b8 100644 --- a/packages/openai-codex-auth/src/request/fetch-helpers.ts +++ b/packages/openai-codex-auth/src/request/fetch-helpers.ts @@ -3,7 +3,7 @@ */ import { refreshAccessToken } from "../auth/auth" -import { CODEX_API } from "../constants" +import { CODEX_API, CODEX_HEADERS } from "../constants" export interface TokenManager { accessToken: string @@ -51,13 +51,47 @@ export async function getValidAccessToken(manager: TokenManager): Promise { + try { + const parts = token.split(".") + if (parts.length !== 3) return {} + const payload = parts[1] + const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")) + return JSON.parse(decoded) + } catch { + return {} + } +} + +/** + * Extract ChatGPT account ID from JWT token + */ +export function extractAccountId(accessToken: string): string | undefined { + const claims = decodeJWT(accessToken) + const authClaims = claims[CODEX_HEADERS.jwtClaimPath] as Record | undefined + return authClaims?.["organization_id"] as string | undefined +} + +/** + * Create authorization headers for Codex API requests */ export function createAuthHeaders(accessToken: string): Record { - return { + const headers: Record = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", + "OpenAI-Beta": CODEX_HEADERS.openAiBeta, + originator: CODEX_HEADERS.originator, } + + // Extract and add account ID from JWT if available + const accountId = extractAccountId(accessToken) + if (accountId) { + headers["chatgpt-account-id"] = accountId + } + + return headers } /** From 42cb16ed9f8049fc1aa9519aa806e1daa9130a36 Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 08:53:33 -0400 Subject: [PATCH 06/14] fix(openai-codex-auth): add URL rewriting for Codex backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add custom fetch that rewrites /responses to /codex/responses. The SDK sends requests to standard paths but Codex backend expects the /codex prefix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/openai-codex-auth/src/index.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/openai-codex-auth/src/index.ts b/packages/openai-codex-auth/src/index.ts index 5b2ab333b4c..d33690bbccd 100644 --- a/packages/openai-codex-auth/src/index.ts +++ b/packages/openai-codex-auth/src/index.ts @@ -31,6 +31,21 @@ export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { return {} } + /** + * Custom fetch that rewrites URLs for the Codex backend + * The SDK sends to /responses but Codex expects /codex/responses + */ + const codexFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + let url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + + // Rewrite /responses to /codex/responses for the Codex backend + if (url.includes("/responses") && !url.includes("/codex/responses")) { + url = url.replace("/responses", "/codex/responses") + } + + return fetch(url, init) + } + /** * Build SDK options with Codex backend configuration */ @@ -48,6 +63,7 @@ export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { apiKey: accessToken, baseURL: CODEX_API.baseUrl, headers, + fetch: codexFetch, ...(refreshedAuth ? { _refreshedAuth: refreshedAuth } : {}), } } From 862f3a79e016892c212f795cbffe28e047ad3cf1 Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 08:59:26 -0400 Subject: [PATCH 07/14] fix(openai-codex-auth): add required instructions field for Codex backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex backend requires an 'instructions' field in the request body. Add request body transformation to include default instructions if not present. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/openai-codex-auth/src/index.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/openai-codex-auth/src/index.ts b/packages/openai-codex-auth/src/index.ts index d33690bbccd..9f783c8c696 100644 --- a/packages/openai-codex-auth/src/index.ts +++ b/packages/openai-codex-auth/src/index.ts @@ -32,8 +32,9 @@ export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { } /** - * Custom fetch that rewrites URLs for the Codex backend + * Custom fetch that rewrites URLs and transforms request body for the Codex backend * The SDK sends to /responses but Codex expects /codex/responses + * Codex also requires an 'instructions' field in the request body */ const codexFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { let url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url @@ -43,7 +44,27 @@ export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { url = url.replace("/responses", "/codex/responses") } - return fetch(url, init) + // Transform request body to add required 'instructions' field + let modifiedInit = init + if (init?.body && typeof init.body === "string") { + try { + const body = JSON.parse(init.body) + + // Add instructions if not present (required by Codex backend) + if (!body.instructions) { + body.instructions = "You are a helpful assistant." + } + + modifiedInit = { + ...init, + body: JSON.stringify(body), + } + } catch { + // If body isn't JSON, pass through unchanged + } + } + + return fetch(url, modifiedInit) } /** From b7d8f96b1a3934ad4cc86900bd7b9281e96b14b2 Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 09:01:45 -0400 Subject: [PATCH 08/14] fix(openai-codex-auth): add store:false and extract system message for instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add store:false (required by ChatGPT backend) - Extract system message from input array to use as instructions - Improve default instructions text 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/openai-codex-auth/src/index.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/openai-codex-auth/src/index.ts b/packages/openai-codex-auth/src/index.ts index 9f783c8c696..e5b8d6793d1 100644 --- a/packages/openai-codex-auth/src/index.ts +++ b/packages/openai-codex-auth/src/index.ts @@ -44,15 +44,32 @@ export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { url = url.replace("/responses", "/codex/responses") } - // Transform request body to add required 'instructions' field + // Transform request body for Codex backend requirements let modifiedInit = init if (init?.body && typeof init.body === "string") { try { const body = JSON.parse(init.body) - // Add instructions if not present (required by Codex backend) + // Required by ChatGPT backend + body.store = false + + // Extract system message from input array for instructions + if (!body.instructions && body.input && Array.isArray(body.input)) { + const systemMsg = body.input.find( + (item: { role?: string; type?: string }) => + item.role === "system" || item.type === "message" && item.role === "system" + ) + if (systemMsg?.content) { + const content = Array.isArray(systemMsg.content) + ? systemMsg.content.map((c: { text?: string }) => c.text || "").join("\n") + : systemMsg.content + body.instructions = content + } + } + + // Default instructions if still not set if (!body.instructions) { - body.instructions = "You are a helpful assistant." + body.instructions = "You are a helpful coding assistant. Help the user with their programming tasks." } modifiedInit = { From 4cc46dc7c0aeb80b1084a28c6d40dbdcf001dbd3 Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 09:17:54 -0400 Subject: [PATCH 09/14] fix(openai-codex-auth): use Codex-expected instruction format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex backend expects instructions starting with the Codex identification string "You are Codex, based on GPT-5..." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/openai-codex-auth/src/index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/openai-codex-auth/src/index.ts b/packages/openai-codex-auth/src/index.ts index e5b8d6793d1..4b7a0beabbc 100644 --- a/packages/openai-codex-auth/src/index.ts +++ b/packages/openai-codex-auth/src/index.ts @@ -67,9 +67,17 @@ export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { } } - // Default instructions if still not set + // Default instructions if still not set - must match Codex expected format if (!body.instructions) { - body.instructions = "You are a helpful coding assistant. Help the user with their programming tasks." + body.instructions = `You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. + +You are a helpful coding assistant that helps users with programming tasks. You can read files, write code, and execute commands to help accomplish the user's goals. + +When helping with code: +- Write clean, well-structured code +- Follow best practices for the language being used +- Explain your reasoning when helpful +- Ask clarifying questions if the request is ambiguous` } modifiedInit = { From 0570d261b508e6da85e638adb2579ef54da1540a Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 09:34:40 -0400 Subject: [PATCH 10/14] fix(openai-codex-auth): add more required Codex fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add stream=true (required by Codex backend) - Strip item IDs from input array (stateless operation) - Remove system message from input after extracting to instructions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/openai-codex-auth/src/index.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/openai-codex-auth/src/index.ts b/packages/openai-codex-auth/src/index.ts index 4b7a0beabbc..23df361745a 100644 --- a/packages/openai-codex-auth/src/index.ts +++ b/packages/openai-codex-auth/src/index.ts @@ -52,18 +52,21 @@ export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { // Required by ChatGPT backend body.store = false + body.stream = true // Extract system message from input array for instructions if (!body.instructions && body.input && Array.isArray(body.input)) { const systemMsg = body.input.find( (item: { role?: string; type?: string }) => - item.role === "system" || item.type === "message" && item.role === "system" + item.role === "system" || (item.type === "message" && item.role === "system") ) if (systemMsg?.content) { const content = Array.isArray(systemMsg.content) ? systemMsg.content.map((c: { text?: string }) => c.text || "").join("\n") : systemMsg.content body.instructions = content + // Remove the system message from input since it's now in instructions + body.input = body.input.filter((item: { role?: string }) => item.role !== "system") } } @@ -80,6 +83,14 @@ When helping with code: - Ask clarifying questions if the request is ambiguous` } + // Strip item IDs for stateless operation (required by Codex backend) + if (body.input && Array.isArray(body.input)) { + body.input = body.input.map((item: Record) => { + const { id, ...rest } = item + return rest + }) + } + modifiedInit = { ...init, body: JSON.stringify(body), From b5c64476311e69d6804a43d3e9b69207bffa9d40 Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 09:38:29 -0400 Subject: [PATCH 11/14] fix(openai-codex-auth): use official Codex system prompt from OpenAI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embed the actual Codex system prompt from openai/codex repository. The backend validates instructions content. Source: https://github.com/openai/codex/blob/main/codex-rs/core/gpt_5_codex_prompt.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../openai-codex-auth/src/codex-prompt.ts | 40 +++++++++++++++++++ packages/openai-codex-auth/src/index.ts | 13 ++---- 2 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 packages/openai-codex-auth/src/codex-prompt.ts diff --git a/packages/openai-codex-auth/src/codex-prompt.ts b/packages/openai-codex-auth/src/codex-prompt.ts new file mode 100644 index 00000000000..9ceef40edd6 --- /dev/null +++ b/packages/openai-codex-auth/src/codex-prompt.ts @@ -0,0 +1,40 @@ +/** + * Official Codex system prompt from OpenAI + * Source: https://github.com/openai/codex/blob/main/codex-rs/core/gpt_5_codex_prompt.md + */ + +export const CODEX_SYSTEM_PROMPT = `You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. + +## General + +- When searching for text or files, prefer using \`rg\` or \`rg --files\` respectively because \`rg\` is much faster than alternatives like \`grep\`. (If the \`rg\` command is not found, then use alternatives.) + +## Editing constraints + +- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. +- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. +- You may be in a dirty git worktree. + * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user. + * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. +- Do not amend a commit unless explicitly requested to do so. +- **NEVER** use destructive commands like \`git reset --hard\` or \`git checkout --\` unless specifically requested or approved by the user. + +## Codex CLI harness, sandboxing, and approvals + +The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. + +Filesystem sandboxing defines which files can be read or written. The options for \`sandbox_mode\` are: +- **read-only**: The sandbox only permits reading files. +- **workspace-write**: The sandbox permits reading files, and editing files in \`cwd\` and \`writable_roots\`. Editing files in other directories requires approval. +- **danger-full-access**: No filesystem sandboxing - all commands are permitted. + +You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. + +## Presenting your work + +- Default: be very concise; friendly coding teammate tone. +- Ask only when needed; suggest ideas; mirror the user's style. +- For substantial work, summarize clearly. +- Skip heavy formatting for simple confirmations. +- Don't dump large files you've written; reference paths only.` diff --git a/packages/openai-codex-auth/src/index.ts b/packages/openai-codex-auth/src/index.ts index 23df361745a..229adb8e054 100644 --- a/packages/openai-codex-auth/src/index.ts +++ b/packages/openai-codex-auth/src/index.ts @@ -9,6 +9,7 @@ import type { Plugin, AuthHook, AuthOuathResult } from "@opencode-ai/plugin" import { initiateOAuthFlow, refreshAccessToken } from "./auth/auth" import { PROVIDER_ID, AUTH_LABEL, CODEX_API, CODEX_HEADERS } from "./constants" import { isTokenExpired, extractAccountId } from "./request/fetch-helpers" +import { CODEX_SYSTEM_PROMPT } from "./codex-prompt" /** * OpenAI Codex Authentication Plugin @@ -70,17 +71,9 @@ export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { } } - // Default instructions if still not set - must match Codex expected format + // Default instructions - use official Codex system prompt if (!body.instructions) { - body.instructions = `You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. - -You are a helpful coding assistant that helps users with programming tasks. You can read files, write code, and execute commands to help accomplish the user's goals. - -When helping with code: -- Write clean, well-structured code -- Follow best practices for the language being used -- Explain your reasoning when helpful -- Ask clarifying questions if the request is ambiguous` + body.instructions = CODEX_SYSTEM_PROMPT } // Strip item IDs for stateless operation (required by Codex backend) From a557ebf5189f6a6ec71f1338c230ebae807e8774 Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 09:47:24 -0400 Subject: [PATCH 12/14] fix(openai-codex-auth): remove instructions, let backend use defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete instructions field entirely - backend validates strictly and uses model-specific defaults. Also remove system messages from input. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/openai-codex-auth/src/index.ts | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/openai-codex-auth/src/index.ts b/packages/openai-codex-auth/src/index.ts index 229adb8e054..b7a939b0c2b 100644 --- a/packages/openai-codex-auth/src/index.ts +++ b/packages/openai-codex-auth/src/index.ts @@ -55,26 +55,14 @@ export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { body.store = false body.stream = true - // Extract system message from input array for instructions - if (!body.instructions && body.input && Array.isArray(body.input)) { - const systemMsg = body.input.find( - (item: { role?: string; type?: string }) => - item.role === "system" || (item.type === "message" && item.role === "system") - ) - if (systemMsg?.content) { - const content = Array.isArray(systemMsg.content) - ? systemMsg.content.map((c: { text?: string }) => c.text || "").join("\n") - : systemMsg.content - body.instructions = content - // Remove the system message from input since it's now in instructions - body.input = body.input.filter((item: { role?: string }) => item.role !== "system") - } + // Remove any system messages from input - Codex backend handles instructions differently + if (body.input && Array.isArray(body.input)) { + body.input = body.input.filter((item: { role?: string }) => item.role !== "system") } - // Default instructions - use official Codex system prompt - if (!body.instructions) { - body.instructions = CODEX_SYSTEM_PROMPT - } + // Delete any existing instructions - backend will use model-specific defaults + // DO NOT set instructions manually - backend validates them strictly + delete body.instructions // Strip item IDs for stateless operation (required by Codex backend) if (body.input && Array.isArray(body.input)) { From e9e42d813afe6aa5503ab639e9611ea557b14133 Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 09:53:42 -0400 Subject: [PATCH 13/14] fix(plugin): use external opencode-openai-codex-auth plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The external plugin has proper prompt alignment with the Codex backend. Use it as primary with built-in as fallback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/opencode/src/plugin/index.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 884ec9d07ea..1905db5b0c6 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -48,14 +48,26 @@ export namespace Plugin { } } - // Load built-in plugins + // Load OpenAI Codex auth - use external plugin which has proper prompt alignment if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { try { - log.info("loading built-in plugin", { name: "openai-codex-auth" }) - const openaiCodexHooks = await OpenAICodexAuthPlugin(input) - hooks.push(openaiCodexHooks) + log.info("loading external plugin", { name: "opencode-openai-codex-auth" }) + const codexPlugin = await BunProc.install("opencode-openai-codex-auth", "latest") + const codexMod = await import(codexPlugin) + for (const [_name, fn] of Object.entries(codexMod)) { + const init = await fn(input) + hooks.push(init) + } } catch (err) { - log.error("failed to load openai-codex-auth plugin", { error: err }) + log.error("failed to load opencode-openai-codex-auth plugin", { error: err }) + // Fallback to built-in plugin + try { + log.info("falling back to built-in plugin", { name: "openai-codex-auth" }) + const openaiCodexHooks = await OpenAICodexAuthPlugin(input) + hooks.push(openaiCodexHooks) + } catch (fallbackErr) { + log.error("failed to load built-in openai-codex-auth plugin", { error: fallbackErr }) + } } } From e389462fbd9476b5408c5aff9f9be3aa8d7bbcd4 Mon Sep 17 00:00:00 2001 From: brettheap Date: Tue, 30 Dec 2025 18:36:30 -0400 Subject: [PATCH 14/14] refactor: remove broken built-in openai-codex-auth plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use external opencode-openai-codex-auth plugin instead, which has proper prompt alignment with the Codex backend. The built-in version was added but never worked correctly. Changes: - Remove packages/openai-codex-auth/ directory - Remove fallback logic in plugin loader - Update dependencies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- bun.lock | 26 --- .../assets/oauth-success.html | 122 ------------ packages/openai-codex-auth/package.json | 30 --- packages/openai-codex-auth/src/auth/auth.ts | 178 ------------------ .../openai-codex-auth/src/auth/browser.ts | 45 ----- packages/openai-codex-auth/src/auth/server.ts | 152 --------------- .../openai-codex-auth/src/codex-prompt.ts | 40 ---- packages/openai-codex-auth/src/constants.ts | 41 ---- packages/openai-codex-auth/src/index.ts | 172 ----------------- .../src/request/fetch-helpers.ts | 116 ------------ packages/openai-codex-auth/src/types.ts | 52 ----- packages/openai-codex-auth/tsconfig.json | 12 -- packages/opencode/package.json | 1 - packages/opencode/src/plugin/index.ts | 13 +- 14 files changed, 2 insertions(+), 998 deletions(-) delete mode 100644 packages/openai-codex-auth/assets/oauth-success.html delete mode 100644 packages/openai-codex-auth/package.json delete mode 100644 packages/openai-codex-auth/src/auth/auth.ts delete mode 100644 packages/openai-codex-auth/src/auth/browser.ts delete mode 100644 packages/openai-codex-auth/src/auth/server.ts delete mode 100644 packages/openai-codex-auth/src/codex-prompt.ts delete mode 100644 packages/openai-codex-auth/src/constants.ts delete mode 100644 packages/openai-codex-auth/src/index.ts delete mode 100644 packages/openai-codex-auth/src/request/fetch-helpers.ts delete mode 100644 packages/openai-codex-auth/src/types.ts delete mode 100644 packages/openai-codex-auth/tsconfig.json diff --git a/bun.lock b/bun.lock index 67d5721360b..b6318af40ed 100644 --- a/bun.lock +++ b/bun.lock @@ -243,23 +243,6 @@ "typescript": "catalog:", }, }, - "packages/openai-codex-auth": { - "name": "@opencode-ai/openai-codex-auth", - "version": "1.0.0", - "dependencies": { - "@openauthjs/openauth": "^0.4.3", - "hono": "^4.10.4", - }, - "devDependencies": { - "@tsconfig/node22": "catalog:", - "@types/node": "catalog:", - "@typescript/native-preview": "catalog:", - "typescript": "catalog:", - }, - "peerDependencies": { - "@opencode-ai/plugin": "workspace:*", - }, - }, "packages/opencode": { "name": "opencode", "version": "1.0.209", @@ -296,7 +279,6 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", - "@opencode-ai/openai-codex-auth": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -1193,8 +1175,6 @@ "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], - "@opencode-ai/openai-codex-auth": ["@opencode-ai/openai-codex-auth@workspace:packages/openai-codex-auth"], - "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], @@ -4139,8 +4119,6 @@ "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], - "@opencode-ai/openai-codex-auth/@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="], - "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="], "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], @@ -4731,10 +4709,6 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], - "@opencode-ai/openai-codex-auth/@openauthjs/openauth/@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="], - - "@opencode-ai/openai-codex-auth/@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AG8vnSi1W2pbgR2B911EfGqtLE9c4hQBYkv/x7Z+Kt0VxhgQKcW7UNDVYsu9YxwV6u+OJrvdJrMq6DNWoBjihQ=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="], diff --git a/packages/openai-codex-auth/assets/oauth-success.html b/packages/openai-codex-auth/assets/oauth-success.html deleted file mode 100644 index 02144de8376..00000000000 --- a/packages/openai-codex-auth/assets/oauth-success.html +++ /dev/null @@ -1,122 +0,0 @@ - - - - - - Authentication Successful - OpenCode - - - -
-
- - - -
- -

Authentication Successful!

- -

- You've successfully connected your ChatGPT account to OpenCode. - You can now use OpenAI models with your subscription. -

- -
- You can close this window and return to your terminal. -
- -
- Powered by OpenCode -
-
- - - - diff --git a/packages/openai-codex-auth/package.json b/packages/openai-codex-auth/package.json deleted file mode 100644 index d0f9c299fb6..00000000000 --- a/packages/openai-codex-auth/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/package.json", - "name": "@opencode-ai/openai-codex-auth", - "version": "1.0.0", - "type": "module", - "scripts": { - "typecheck": "tsgo --noEmit", - "build": "tsc" - }, - "exports": { - ".": "./src/index.ts" - }, - "files": [ - "dist", - "assets" - ], - "dependencies": { - "@openauthjs/openauth": "^0.4.3", - "hono": "^4.10.4" - }, - "peerDependencies": { - "@opencode-ai/plugin": "workspace:*" - }, - "devDependencies": { - "@tsconfig/node22": "catalog:", - "@types/node": "catalog:", - "typescript": "catalog:", - "@typescript/native-preview": "catalog:" - } -} diff --git a/packages/openai-codex-auth/src/auth/auth.ts b/packages/openai-codex-auth/src/auth/auth.ts deleted file mode 100644 index d452c5b3eea..00000000000 --- a/packages/openai-codex-auth/src/auth/auth.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Core OAuth implementation with PKCE - */ - -import { randomBytes, createHash } from "crypto" -import { OAUTH_CONFIG } from "../constants" -import type { PKCEChallenge, OAuthTokenResponse, OAuthError, AuthCallbackResult } from "../types" -import { openBrowser } from "./browser" -import { startCallbackServer } from "./server" - -/** - * Generate a cryptographically secure random string - */ -function generateRandomString(length: number): string { - return randomBytes(length).toString("base64url").slice(0, length) -} - -/** - * Create a SHA256 hash and return as base64url - */ -function sha256(input: string): string { - return createHash("sha256").update(input).digest("base64url") -} - -/** - * Generate PKCE challenge (code_verifier and code_challenge) - */ -export function createPKCEChallenge(): PKCEChallenge { - // Generate a random 43-128 character code verifier - const codeVerifier = generateRandomString(64) - // Create the code challenge using S256 method - const codeChallenge = sha256(codeVerifier) - // Generate state for CSRF protection - const state = generateRandomString(32) - - return { - codeVerifier, - codeChallenge, - state, - } -} - -/** - * Build the authorization URL with PKCE parameters - */ -export function buildAuthorizationUrl(pkce: PKCEChallenge): string { - const params = new URLSearchParams({ - client_id: OAUTH_CONFIG.clientId, - redirect_uri: OAUTH_CONFIG.redirectUri, - response_type: "code", - scope: OAUTH_CONFIG.scopes.join(" "), - state: pkce.state, - code_challenge: pkce.codeChallenge, - code_challenge_method: "S256", - audience: OAUTH_CONFIG.audience, - }) - - return `${OAUTH_CONFIG.authorizationUrl}?${params.toString()}` -} - -/** - * Exchange authorization code for tokens - */ -export async function exchangeCodeForTokens( - code: string, - codeVerifier: string -): Promise { - const response = await fetch(OAUTH_CONFIG.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: OAUTH_CONFIG.clientId, - code, - redirect_uri: OAUTH_CONFIG.redirectUri, - code_verifier: codeVerifier, - }), - }) - - if (!response.ok) { - const error = (await response.json()) as OAuthError - throw new Error(`Token exchange failed: ${error.error_description || error.error}`) - } - - return response.json() as Promise -} - -/** - * Refresh an access token using the refresh token - */ -export async function refreshAccessToken(refreshToken: string): Promise { - const response = await fetch(OAUTH_CONFIG.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - client_id: OAUTH_CONFIG.clientId, - refresh_token: refreshToken, - }), - }) - - if (!response.ok) { - const error = (await response.json()) as OAuthError - throw new Error(`Token refresh failed: ${error.error_description || error.error}`) - } - - return response.json() as Promise -} - -/** - * Decode a JWT token to extract claims (without verification) - */ -export function decodeJWT(token: string): Record { - try { - const parts = token.split(".") - if (parts.length !== 3) { - return {} - } - const payload = Buffer.from(parts[1], "base64url").toString("utf-8") - return JSON.parse(payload) - } catch { - return {} - } -} - -/** - * Perform the complete OAuth flow - * Returns authorization URL and callback handler - */ -export async function initiateOAuthFlow(): Promise<{ - url: string - instructions: string - method: "auto" - callback: () => Promise -}> { - const pkce = createPKCEChallenge() - const authUrl = buildAuthorizationUrl(pkce) - - // Start the callback server before returning - const callbackPromise = startCallbackServer(pkce.state) - - return { - url: authUrl, - instructions: "Complete the authentication in your browser. You will be redirected back automatically.", - method: "auto" as const, - callback: async (): Promise => { - try { - // Open browser - await openBrowser(authUrl) - - // Wait for callback - const result = await callbackPromise - - // Exchange code for tokens - const tokens = await exchangeCodeForTokens(result.code, pkce.codeVerifier) - - // Calculate expiry time - const expiresAt = Date.now() + tokens.expires_in * 1000 - - return { - type: "success", - refresh: tokens.refresh_token, - access: tokens.access_token, - expires: expiresAt, - } - } catch (error) { - return { - type: "failed", - error: error instanceof Error ? error.message : "Unknown error", - } - } - }, - } -} diff --git a/packages/openai-codex-auth/src/auth/browser.ts b/packages/openai-codex-auth/src/auth/browser.ts deleted file mode 100644 index b3e072b95a9..00000000000 --- a/packages/openai-codex-auth/src/auth/browser.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Cross-platform browser launching utility - */ - -import { spawn } from "child_process" - -/** - * Opens a URL in the default system browser - */ -export async function openBrowser(url: string): Promise { - const platform = process.platform - - let command: string - let args: string[] - - switch (platform) { - case "darwin": - command = "open" - args = [url] - break - case "win32": - command = "cmd" - args = ["/c", "start", "", url] - break - default: - // Linux and others - command = "xdg-open" - args = [url] - break - } - - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - detached: true, - stdio: "ignore", - }) - - child.on("error", (err) => { - reject(new Error(`Failed to open browser: ${err.message}`)) - }) - - child.unref() - resolve() - }) -} diff --git a/packages/openai-codex-auth/src/auth/server.ts b/packages/openai-codex-auth/src/auth/server.ts deleted file mode 100644 index 6f682675a0d..00000000000 --- a/packages/openai-codex-auth/src/auth/server.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Local OAuth callback server - * Listens on localhost for the OAuth redirect - */ - -import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http" -import { LOCAL_SERVER } from "../constants" -import type { AuthorizationResult } from "../types" -import { readFileSync } from "fs" -import { join, dirname } from "path" -import { fileURLToPath } from "url" - -const __dirname = dirname(fileURLToPath(import.meta.url)) - -/** - * Starts a local HTTP server to receive the OAuth callback - * Returns a promise that resolves with the authorization code - */ -export function startCallbackServer(expectedState: string): Promise { - return new Promise((resolve, reject) => { - let server: Server | null = null - let timeoutId: ReturnType | null = null - - const cleanup = () => { - if (timeoutId) { - clearTimeout(timeoutId) - timeoutId = null - } - if (server) { - server.close() - server = null - } - } - - // 60 second timeout - timeoutId = setTimeout(() => { - cleanup() - reject(new Error("OAuth callback timeout - no response received within 60 seconds")) - }, 60000) - - server = createServer((req: IncomingMessage, res: ServerResponse) => { - const url = new URL(req.url || "", `http://${LOCAL_SERVER.host}:${LOCAL_SERVER.port}`) - - if (url.pathname === LOCAL_SERVER.callbackPath) { - 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) { - // Serve error page - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(` - - - Authentication Failed - -
-

Authentication Failed

-

${errorDescription || error}

-

You can close this window.

-
- - - `) - cleanup() - reject(new Error(`OAuth error: ${errorDescription || error}`)) - return - } - - if (!code || !state) { - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(` - - - Invalid Request - -
-

Invalid Request

-

Missing authorization code or state parameter.

-
- - - `) - cleanup() - reject(new Error("Invalid OAuth callback - missing code or state")) - return - } - - if (state !== expectedState) { - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(` - - - Security Error - -
-

Security Error

-

State parameter mismatch. This could be a CSRF attack.

-
- - - `) - cleanup() - reject(new Error("OAuth state mismatch - possible CSRF attack")) - return - } - - // Success! Serve success page - let successHtml: string - try { - successHtml = readFileSync(join(__dirname, "../../assets/oauth-success.html"), "utf-8") - } catch { - successHtml = ` - - - Authentication Successful - -
-

Authentication Successful!

-

You can close this window and return to the terminal.

-
- - - ` - } - - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(successHtml) - - cleanup() - resolve({ code, state }) - } else { - res.writeHead(404) - res.end("Not found") - } - }) - - server.on("error", (err: NodeJS.ErrnoException) => { - cleanup() - if (err.code === "EADDRINUSE") { - reject(new Error(`Port ${LOCAL_SERVER.port} is already in use. Please stop any other OAuth servers.`)) - } else { - reject(err) - } - }) - - server.listen(LOCAL_SERVER.port, LOCAL_SERVER.host, () => { - // Server is ready - }) - }) -} diff --git a/packages/openai-codex-auth/src/codex-prompt.ts b/packages/openai-codex-auth/src/codex-prompt.ts deleted file mode 100644 index 9ceef40edd6..00000000000 --- a/packages/openai-codex-auth/src/codex-prompt.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Official Codex system prompt from OpenAI - * Source: https://github.com/openai/codex/blob/main/codex-rs/core/gpt_5_codex_prompt.md - */ - -export const CODEX_SYSTEM_PROMPT = `You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. - -## General - -- When searching for text or files, prefer using \`rg\` or \`rg --files\` respectively because \`rg\` is much faster than alternatives like \`grep\`. (If the \`rg\` command is not found, then use alternatives.) - -## Editing constraints - -- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. -- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. -- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. -- You may be in a dirty git worktree. - * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user. - * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. -- Do not amend a commit unless explicitly requested to do so. -- **NEVER** use destructive commands like \`git reset --hard\` or \`git checkout --\` unless specifically requested or approved by the user. - -## Codex CLI harness, sandboxing, and approvals - -The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. - -Filesystem sandboxing defines which files can be read or written. The options for \`sandbox_mode\` are: -- **read-only**: The sandbox only permits reading files. -- **workspace-write**: The sandbox permits reading files, and editing files in \`cwd\` and \`writable_roots\`. Editing files in other directories requires approval. -- **danger-full-access**: No filesystem sandboxing - all commands are permitted. - -You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. - -## Presenting your work - -- Default: be very concise; friendly coding teammate tone. -- Ask only when needed; suggest ideas; mirror the user's style. -- For substantial work, summarize clearly. -- Skip heavy formatting for simple confirmations. -- Don't dump large files you've written; reference paths only.` diff --git a/packages/openai-codex-auth/src/constants.ts b/packages/openai-codex-auth/src/constants.ts deleted file mode 100644 index abeffb6c1f3..00000000000 --- a/packages/openai-codex-auth/src/constants.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * OpenAI OAuth Configuration Constants - * These are the official OAuth credentials used by the Codex CLI - */ - -export const OAUTH_CONFIG = { - clientId: "app_EMoamEEZ73f0CkXaXp7hrann", - authorizationUrl: "https://auth.openai.com/oauth/authorize", - tokenUrl: "https://auth.openai.com/oauth/token", - redirectUri: "http://localhost:1455/auth/callback", - scopes: ["openid", "profile", "email", "offline_access"], - audience: "https://api.openai.com/v1", -} as const - -export const LOCAL_SERVER = { - host: "0.0.0.0", // Bind to all interfaces for WSL2 compatibility - port: 1455, - callbackPath: "/auth/callback", -} as const - -export const CODEX_API = { - baseUrl: "https://chatgpt.com/backend-api", - responsesEndpoint: "/codex/responses", -} as const - -export const CODEX_HEADERS = { - openAiBeta: "responses=experimental", - originator: "codex_cli_rs", - jwtClaimPath: "https://api.openai.com/auth", -} as const - -export const PLUGIN_NAME = "openai-codex-auth" -export const PROVIDER_ID = "openai" -export const AUTH_LABEL = "ChatGPT Plus/Pro (Codex Subscription)" - -/** - * Timeout for OAuth polling (in milliseconds) - * 60 seconds = 600 iterations * 100ms - */ -export const OAUTH_TIMEOUT_MS = 60000 -export const OAUTH_POLL_INTERVAL_MS = 100 diff --git a/packages/openai-codex-auth/src/index.ts b/packages/openai-codex-auth/src/index.ts deleted file mode 100644 index b7a939b0c2b..00000000000 --- a/packages/openai-codex-auth/src/index.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * OpenAI Codex Auth Plugin for OpenCode - * - * Enables ChatGPT Plus/Pro subscribers to use OpenAI models via OAuth - * Uses the same authentication flow as the official Codex CLI - */ - -import type { Plugin, AuthHook, AuthOuathResult } from "@opencode-ai/plugin" -import { initiateOAuthFlow, refreshAccessToken } from "./auth/auth" -import { PROVIDER_ID, AUTH_LABEL, CODEX_API, CODEX_HEADERS } from "./constants" -import { isTokenExpired, extractAccountId } from "./request/fetch-helpers" -import { CODEX_SYSTEM_PROMPT } from "./codex-prompt" - -/** - * OpenAI Codex Authentication Plugin - * - * Provides OAuth authentication for OpenAI API access using - * ChatGPT Plus/Pro subscription credentials. - */ -export const OpenAICodexAuthPlugin: Plugin = async (_ctx) => { - const authHook: AuthHook = { - provider: PROVIDER_ID, - - /** - * Loader function called when the provider needs authentication - * Handles token refresh and returns SDK options for Codex backend - */ - loader: async (getAuth, _provider) => { - const auth = await getAuth() - - if (auth.type !== "oauth") { - return {} - } - - /** - * Custom fetch that rewrites URLs and transforms request body for the Codex backend - * The SDK sends to /responses but Codex expects /codex/responses - * Codex also requires an 'instructions' field in the request body - */ - const codexFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - let url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url - - // Rewrite /responses to /codex/responses for the Codex backend - if (url.includes("/responses") && !url.includes("/codex/responses")) { - url = url.replace("/responses", "/codex/responses") - } - - // Transform request body for Codex backend requirements - let modifiedInit = init - if (init?.body && typeof init.body === "string") { - try { - const body = JSON.parse(init.body) - - // Required by ChatGPT backend - body.store = false - body.stream = true - - // Remove any system messages from input - Codex backend handles instructions differently - if (body.input && Array.isArray(body.input)) { - body.input = body.input.filter((item: { role?: string }) => item.role !== "system") - } - - // Delete any existing instructions - backend will use model-specific defaults - // DO NOT set instructions manually - backend validates them strictly - delete body.instructions - - // Strip item IDs for stateless operation (required by Codex backend) - if (body.input && Array.isArray(body.input)) { - body.input = body.input.map((item: Record) => { - const { id, ...rest } = item - return rest - }) - } - - modifiedInit = { - ...init, - body: JSON.stringify(body), - } - } catch { - // If body isn't JSON, pass through unchanged - } - } - - return fetch(url, modifiedInit) - } - - /** - * Build SDK options with Codex backend configuration - */ - const buildOptions = (accessToken: string, refreshedAuth?: object) => { - const accountId = extractAccountId(accessToken) - const headers: Record = { - "OpenAI-Beta": CODEX_HEADERS.openAiBeta, - originator: CODEX_HEADERS.originator, - } - if (accountId) { - headers["chatgpt-account-id"] = accountId - } - - return { - apiKey: accessToken, - baseURL: CODEX_API.baseUrl, - headers, - fetch: codexFetch, - ...(refreshedAuth ? { _refreshedAuth: refreshedAuth } : {}), - } - } - - // Check if token needs refresh - if (isTokenExpired(auth.expires)) { - try { - const tokens = await refreshAccessToken(auth.refresh) - const newExpires = Date.now() + tokens.expires_in * 1000 - - return buildOptions(tokens.access_token, { - type: "oauth" as const, - access: tokens.access_token, - refresh: tokens.refresh_token, - expires: newExpires, - }) - } catch (error) { - console.error("[openai-codex-auth] Failed to refresh token:", error) - // Return existing token and let the API call fail if it's truly expired - return buildOptions(auth.access) - } - } - - return buildOptions(auth.access) - }, - - methods: [ - { - type: "oauth", - label: AUTH_LABEL, - - /** - * Initiate the OAuth flow - */ - authorize: async (_inputs?: Record): Promise => { - const flow = await initiateOAuthFlow() - - return { - url: flow.url, - instructions: flow.instructions, - method: "auto" as const, - callback: async () => { - const result = await flow.callback() - - if (result.type === "failed") { - return { type: "failed" as const } - } - - return { - type: "success" as const, - refresh: result.refresh, - access: result.access, - expires: result.expires, - } - }, - } - }, - }, - ], - } - - return { - auth: authHook, - } -} - -// Default export for compatibility -export default OpenAICodexAuthPlugin diff --git a/packages/openai-codex-auth/src/request/fetch-helpers.ts b/packages/openai-codex-auth/src/request/fetch-helpers.ts deleted file mode 100644 index 266845d01b8..00000000000 --- a/packages/openai-codex-auth/src/request/fetch-helpers.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Fetch helpers for token management and API requests - */ - -import { refreshAccessToken } from "../auth/auth" -import { CODEX_API, CODEX_HEADERS } from "../constants" - -export interface TokenManager { - accessToken: string - refreshToken: string - expiresAt: number - onTokenRefresh?: (tokens: { access: string; refresh: string; expires: number }) => void -} - -/** - * Check if the access token is expired or about to expire - * Considers token expired if it expires within 5 minutes - */ -export function isTokenExpired(expiresAt: number): boolean { - const bufferMs = 5 * 60 * 1000 // 5 minutes - return Date.now() >= expiresAt - bufferMs -} - -/** - * Get a valid access token, refreshing if necessary - */ -export async function getValidAccessToken(manager: TokenManager): Promise { - if (!isTokenExpired(manager.expiresAt)) { - return manager.accessToken - } - - // Token is expired, refresh it - const tokens = await refreshAccessToken(manager.refreshToken) - const newExpiresAt = Date.now() + tokens.expires_in * 1000 - - // Update manager - manager.accessToken = tokens.access_token - manager.refreshToken = tokens.refresh_token - manager.expiresAt = newExpiresAt - - // Notify callback if provided - if (manager.onTokenRefresh) { - manager.onTokenRefresh({ - access: tokens.access_token, - refresh: tokens.refresh_token, - expires: newExpiresAt, - }) - } - - return tokens.access_token -} - -/** - * Decode a JWT token and extract claims (without verification) - */ -export function decodeJWT(token: string): Record { - try { - const parts = token.split(".") - if (parts.length !== 3) return {} - const payload = parts[1] - const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")) - return JSON.parse(decoded) - } catch { - return {} - } -} - -/** - * Extract ChatGPT account ID from JWT token - */ -export function extractAccountId(accessToken: string): string | undefined { - const claims = decodeJWT(accessToken) - const authClaims = claims[CODEX_HEADERS.jwtClaimPath] as Record | undefined - return authClaims?.["organization_id"] as string | undefined -} - -/** - * Create authorization headers for Codex API requests - */ -export function createAuthHeaders(accessToken: string): Record { - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "OpenAI-Beta": CODEX_HEADERS.openAiBeta, - originator: CODEX_HEADERS.originator, - } - - // Extract and add account ID from JWT if available - const accountId = extractAccountId(accessToken) - if (accountId) { - headers["chatgpt-account-id"] = accountId - } - - return headers -} - -/** - * Make an authenticated API request to OpenAI - */ -export async function authenticatedFetch( - manager: TokenManager, - endpoint: string, - options: RequestInit = {} -): Promise { - const accessToken = await getValidAccessToken(manager) - - const url = endpoint.startsWith("http") ? endpoint : `${CODEX_API.baseUrl}${endpoint}` - - return fetch(url, { - ...options, - headers: { - ...createAuthHeaders(accessToken), - ...options.headers, - }, - }) -} diff --git a/packages/openai-codex-auth/src/types.ts b/packages/openai-codex-auth/src/types.ts deleted file mode 100644 index 8a519a9d483..00000000000 --- a/packages/openai-codex-auth/src/types.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * TypeScript type definitions for OpenAI Codex Auth Plugin - */ - -export interface OAuthTokenResponse { - access_token: string - refresh_token: string - token_type: string - expires_in: number - scope: string - id_token?: string -} - -export interface OAuthError { - error: string - error_description?: string -} - -export interface PKCEChallenge { - codeVerifier: string - codeChallenge: string - state: string -} - -export interface AuthorizationResult { - code: string - state: string -} - -export interface TokenInfo { - accessToken: string - refreshToken: string - expiresAt: number -} - -export interface DecodedJWT { - sub?: string - email?: string - name?: string - exp?: number - iat?: number -} - -export type AuthCallbackResult = { - type: "success" - refresh: string - access: string - expires: number -} | { - type: "failed" - error?: string -} diff --git a/packages/openai-codex-auth/tsconfig.json b/packages/openai-codex-auth/tsconfig.json deleted file mode 100644 index 58072c81c96..00000000000 --- a/packages/openai-codex-auth/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig.json", - "extends": "@tsconfig/node22/tsconfig.json", - "compilerOptions": { - "outDir": "dist", - "module": "preserve", - "declaration": true, - "moduleResolution": "bundler", - "lib": ["es2022", "dom", "dom.iterable"] - }, - "include": ["src"] -} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index eeb2046b970..e9650145a3a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -75,7 +75,6 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", - "@opencode-ai/openai-codex-auth": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 1905db5b0c6..71e4627050a 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -7,7 +7,6 @@ import { Server } from "../server/server" import { BunProc } from "../bun" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" -import { OpenAICodexAuthPlugin } from "@opencode-ai/openai-codex-auth" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -48,10 +47,10 @@ export namespace Plugin { } } - // Load OpenAI Codex auth - use external plugin which has proper prompt alignment + // Load OpenAI Codex auth plugin for ChatGPT Plus/Pro subscribers if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { try { - log.info("loading external plugin", { name: "opencode-openai-codex-auth" }) + log.info("loading plugin", { name: "opencode-openai-codex-auth" }) const codexPlugin = await BunProc.install("opencode-openai-codex-auth", "latest") const codexMod = await import(codexPlugin) for (const [_name, fn] of Object.entries(codexMod)) { @@ -60,14 +59,6 @@ export namespace Plugin { } } catch (err) { log.error("failed to load opencode-openai-codex-auth plugin", { error: err }) - // Fallback to built-in plugin - try { - log.info("falling back to built-in plugin", { name: "openai-codex-auth" }) - const openaiCodexHooks = await OpenAICodexAuthPlugin(input) - hooks.push(openaiCodexHooks) - } catch (fallbackErr) { - log.error("failed to load built-in openai-codex-auth plugin", { error: fallbackErr }) - } } }