From 898118bafbf8a12c0ef9d6673d36305472991906 Mon Sep 17 00:00:00 2001 From: Rohan Godha Date: Tue, 27 Jan 2026 19:05:52 -0500 Subject: [PATCH] feat: support headless authentication for chatgpt/codex (#10890) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline --- packages/opencode/src/plugin/codex.ts | 86 ++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 198e8ce25..b6f1a96a9 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -10,6 +10,7 @@ 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 +const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 interface PkceCodes { verifier: string @@ -461,7 +462,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { }, methods: [ { - label: "ChatGPT Pro/Plus", + label: "ChatGPT Pro/Plus (browser)", type: "oauth", authorize: async () => { const { redirectUri } = await startOAuthServer() @@ -490,6 +491,89 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { } }, }, + { + label: "ChatGPT Pro/Plus (headless)", + type: "oauth", + authorize: async () => { + const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": `opencode/${Installation.VERSION}`, + }, + body: JSON.stringify({ client_id: CLIENT_ID }), + }) + + if (!deviceResponse.ok) throw new Error("Failed to initiate device authorization") + + const deviceData = (await deviceResponse.json()) as { + device_auth_id: string + user_code: string + interval: string + } + const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000 + + return { + url: `${ISSUER}/codex/device`, + instructions: `Enter code: ${deviceData.user_code}`, + method: "auto" as const, + async callback() { + while (true) { + const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": `opencode/${Installation.VERSION}`, + }, + body: JSON.stringify({ + device_auth_id: deviceData.device_auth_id, + user_code: deviceData.user_code, + }), + }) + + if (response.ok) { + const data = (await response.json()) as { + authorization_code: string + code_verifier: string + } + + const tokenResponse = await fetch(`${ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: data.authorization_code, + redirect_uri: `${ISSUER}/deviceauth/callback`, + client_id: CLIENT_ID, + code_verifier: data.code_verifier, + }).toString(), + }) + + if (!tokenResponse.ok) { + throw new Error(`Token exchange failed: ${tokenResponse.status}`) + } + + const tokens: TokenResponse = await tokenResponse.json() + + return { + type: "success" as const, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + accountId: extractAccountId(tokens), + } + } + + if (response.status !== 403 && response.status !== 404) { + return { type: "failed" as const } + } + + await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS) + } + }, + } + }, + }, { label: "Manually enter API Key", type: "api",