diff --git a/CHANGELOG.md b/CHANGELOG.md index b56d02a734b..3db67bdb832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - Models/CLI: show bundled provider-owned static catalog rows in `models list --all` before auth is configured, including Kimi K2.6 rows for Moonshot, OpenRouter, and Vercel AI Gateway, while keeping local-only and workspace plugin catalog paths isolated. (#69909) Thanks @shakkernerd. - Configure: skip generic CLI startup bootstrap for `openclaw configure` and bound hint-only gateway probes so the onboarding TUI reaches its first prompt faster when the Gateway is unavailable. (#69984) Thanks @obviyus. - Agents/harness: surface selected plugin harness failures directly instead of replaying the same turn through embedded PI, preventing misleading secondary PI auth errors and avoiding duplicate side effects. +- OpenAI Codex: add a ChatGPT device-code auth option beside browser OAuth, so headless or callback-hostile setups can sign in without relying on the localhost browser callback. (#69557) Thanks @vincentkoc. ## 2026.4.21 diff --git a/extensions/openai/openai-codex-auth-identity.ts b/extensions/openai/openai-codex-auth-identity.ts index b43fa481316..8fcddc76517 100644 --- a/extensions/openai/openai-codex-auth-identity.ts +++ b/extensions/openai/openai-codex-auth-identity.ts @@ -8,6 +8,7 @@ type CodexJwtPayload = { email?: unknown; }; "https://api.openai.com/auth"?: { + chatgpt_account_id?: unknown; chatgpt_account_user_id?: unknown; chatgpt_user_id?: unknown; user_id?: unknown; diff --git a/extensions/openai/openai-codex-cli-auth.test.ts b/extensions/openai/openai-codex-cli-auth.test.ts index 25e404ccf8c..9954221a77f 100644 --- a/extensions/openai/openai-codex-cli-auth.test.ts +++ b/extensions/openai/openai-codex-cli-auth.test.ts @@ -13,6 +13,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({ import { OPENAI_CODEX_DEFAULT_PROFILE_ID, + hasOpenAICodexCliOAuthCredential, readOpenAICodexCliOAuthProfile, } from "./openai-codex-cli-auth.js"; @@ -80,6 +81,20 @@ describe("readOpenAICodexCliOAuthProfile", () => { expect(parsed?.credential.expires).toBeGreaterThan(Date.now()); }); + it("detects an existing Codex CLI chatgpt login for setup labeling", () => { + vi.spyOn(fs, "readFileSync").mockReturnValue( + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "access-token", + refresh_token: "refresh-token", + }, + }), + ); + + expect(hasOpenAICodexCliOAuthCredential()).toBe(true); + }); + it("does not override a locally managed OpenAI Codex profile", () => { vi.spyOn(fs, "readFileSync").mockReturnValue( JSON.stringify({ diff --git a/extensions/openai/openai-codex-cli-auth.ts b/extensions/openai/openai-codex-cli-auth.ts index e4980f4f262..859bc565b42 100644 --- a/extensions/openai/openai-codex-cli-auth.ts +++ b/extensions/openai/openai-codex-cli-auth.ts @@ -66,6 +66,18 @@ function readCodexCliAuthFile(env: NodeJS.ProcessEnv): CodexCliAuthFile | null { } } +export function hasOpenAICodexCliOAuthCredential(params?: { env?: NodeJS.ProcessEnv }): boolean { + const authFile = readCodexCliAuthFile(params?.env ?? process.env); + if (!authFile || authFile.auth_mode !== "chatgpt") { + return false; + } + + return Boolean( + trimNonEmptyString(authFile.tokens?.access_token) && + trimNonEmptyString(authFile.tokens?.refresh_token), + ); +} + function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean { return ( a.type === b.type && diff --git a/extensions/openai/openai-codex-device-code.test.ts b/extensions/openai/openai-codex-device-code.test.ts new file mode 100644 index 00000000000..e1b3e4df389 --- /dev/null +++ b/extensions/openai/openai-codex-device-code.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveCodexAccessTokenExpiry } from "./openai-codex-auth-identity.js"; +import { loginOpenAICodexDeviceCode } from "./openai-codex-device-code.js"; + +function createJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + +function createJsonResponse(body: unknown, init?: { status?: number }) { + return new Response(JSON.stringify(body), { + status: init?.status ?? 200, + headers: { + "Content-Type": "application/json", + }, + }); +} + +describe("loginOpenAICodexDeviceCode", () => { + it("requests a device code, polls for authorization, and exchanges OAuth tokens", async () => { + vi.useFakeTimers(); + try { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + createJsonResponse({ + device_auth_id: "device-auth-123", + user_code: "CODE-12345", + interval: "0", + }), + ) + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce( + createJsonResponse({ + authorization_code: "authorization-code-123", + code_challenge: "ignored", + code_verifier: "code-verifier-123", + }), + ) + .mockResolvedValueOnce( + createJsonResponse({ + access_token: createJwt({ + exp: Math.floor(Date.now() / 1000) + 600, + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_123", + }, + "https://api.openai.com/profile": { + email: "codex@example.com", + }, + }), + refresh_token: "refresh-token-123", + id_token: createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_123", + }, + }), + expires_in: 600, + }), + ); + const onVerification = vi.fn(async () => {}); + const onProgress = vi.fn(); + + const credentialsPromise = loginOpenAICodexDeviceCode({ + fetchFn: fetchMock as typeof fetch, + onVerification, + onProgress, + }); + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(4_999); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + const credentials = await credentialsPromise; + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://auth.openai.com/api/accounts/deviceauth/usercode", + expect.objectContaining({ + method: "POST", + }), + ); + expect(onVerification).toHaveBeenCalledWith({ + verificationUrl: "https://auth.openai.com/codex/device", + userCode: "CODE-12345", + expiresInMs: 900_000, + }); + expect(onProgress).toHaveBeenNthCalledWith(1, "Requesting device code…"); + expect(onProgress).toHaveBeenNthCalledWith(2, "Waiting for device authorization…"); + expect(onProgress).toHaveBeenNthCalledWith(3, "Exchanging device code…"); + expect(credentials).toMatchObject({ + access: expect.any(String), + refresh: "refresh-token-123", + }); + expect(credentials).not.toHaveProperty("accountId"); + expect(credentials.expires).toBeGreaterThan(Date.now()); + } finally { + vi.useRealTimers(); + } + }); + + it("treats JWT-derived expiry fallback as an absolute timestamp", async () => { + const accessToken = createJwt({ + exp: Math.floor(Date.now() / 1000) + 600, + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_123", + }, + }); + const expectedExpiry = resolveCodexAccessTokenExpiry(accessToken); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + createJsonResponse({ + device_auth_id: "device-auth-123", + user_code: "CODE-12345", + interval: "0", + }), + ) + .mockResolvedValueOnce( + createJsonResponse({ + authorization_code: "authorization-code-123", + code_verifier: "code-verifier-123", + }), + ) + .mockResolvedValueOnce( + createJsonResponse({ + access_token: accessToken, + refresh_token: "refresh-token-123", + }), + ); + + const credentials = await loginOpenAICodexDeviceCode({ + fetchFn: fetchMock as typeof fetch, + onVerification: async () => {}, + }); + + expect(expectedExpiry).toBeDefined(); + expect(credentials.expires).toBe(expectedExpiry); + }); + + it("surfaces user-code request failures", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(`down\r\n\u001B[31mnow\u001B[0m`, { + status: 503, + }), + ); + + await expect( + loginOpenAICodexDeviceCode({ + fetchFn: fetchMock as typeof fetch, + onVerification: async () => {}, + }), + ).rejects.toThrow("OpenAI device code request failed: HTTP 503 down now"); + }); + + it("surfaces device authorization failures with sanitized payload details", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + createJsonResponse({ + device_auth_id: "device-auth-123", + user_code: "CODE-12345", + interval: "0", + }), + ) + .mockResolvedValueOnce( + createJsonResponse( + { + error: "authorization_declined\r\n\u001B[31mspoofed\u001B[0m", + error_description: "Denied\r\nnext line", + }, + { status: 401 }, + ), + ); + + await expect( + loginOpenAICodexDeviceCode({ + fetchFn: fetchMock as typeof fetch, + onVerification: async () => {}, + }), + ).rejects.toThrow( + "OpenAI device authorization failed: authorization_declined spoofed (Denied next line)", + ); + }); + + it("strips C1 terminal controls from reflected device-code errors", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + createJsonResponse({ + device_auth_id: "device-auth-123", + user_code: "CODE-12345", + interval: "0", + }), + ) + .mockResolvedValueOnce( + createJsonResponse( + { + error: `authorization_declined${String.fromCharCode(0x9b)}spoofed`, + error_description: `Denied${String.fromCharCode(0x9d)}next line`, + }, + { status: 401 }, + ), + ); + + await expect( + loginOpenAICodexDeviceCode({ + fetchFn: fetchMock as typeof fetch, + onVerification: async () => {}, + }), + ).rejects.toThrow( + "OpenAI device authorization failed: authorization_declined spoofed (Denied next line)", + ); + }); +}); diff --git a/extensions/openai/openai-codex-device-code.ts b/extensions/openai/openai-codex-device-code.ts new file mode 100644 index 00000000000..0e24f59bf32 --- /dev/null +++ b/extensions/openai/openai-codex-device-code.ts @@ -0,0 +1,305 @@ +import { resolveCodexAccessTokenExpiry } from "./openai-codex-auth-identity.js"; +import { trimNonEmptyString } from "./openai-codex-shared.js"; + +const OPENAI_AUTH_BASE_URL = "https://auth.openai.com"; +const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const OPENAI_CODEX_DEVICE_CODE_TIMEOUT_MS = 15 * 60_000; +const OPENAI_CODEX_DEVICE_CODE_DEFAULT_INTERVAL_MS = 5_000; +const OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS = 1_000; +const OPENAI_CODEX_DEVICE_CALLBACK_URL = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`; + +type OpenAICodexDeviceCodePrompt = { + verificationUrl: string; + userCode: string; + expiresInMs: number; +}; + +type OpenAICodexDeviceCodeCredentials = { + access: string; + refresh: string; + expires: number; +}; + +type DeviceCodeUserCodePayload = { + device_auth_id?: unknown; + user_code?: unknown; + usercode?: unknown; + interval?: unknown; +}; + +type DeviceCodeTokenPayload = { + authorization_code?: unknown; + code_challenge?: unknown; + code_verifier?: unknown; +}; + +type OAuthTokenPayload = { + access_token?: unknown; + refresh_token?: unknown; + expires_in?: unknown; +}; + +type RequestedDeviceCode = { + deviceAuthId: string; + userCode: string; + verificationUrl: string; + intervalMs: number; +}; + +type DeviceCodeAuthorizationCode = { + authorizationCode: string; + codeVerifier: string; +}; + +function normalizePositiveMilliseconds(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.trunc(value * 1000); + } + if (typeof value === "string" && /^\d+$/.test(value.trim())) { + const seconds = Number.parseInt(value.trim(), 10); + return seconds > 0 ? seconds * 1000 : undefined; + } + return undefined; +} + +function normalizeTokenLifetimeMs(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return Math.trunc(value * 1000); + } + if (typeof value === "string" && /^\d+$/.test(value.trim())) { + return Number.parseInt(value.trim(), 10) * 1000; + } + return undefined; +} + +function parseJsonObject(text: string): Record | null { + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === "object" ? (parsed as Record) : null; + } catch { + return null; + } +} + +function sanitizeDeviceCodeErrorText(value: string): string { + const esc = String.fromCharCode(0x1b); + const ansiCsiRegex = new RegExp(`${esc}\\[[\\u0020-\\u003f]*[\\u0040-\\u007e]`, "g"); + const osc8Regex = new RegExp(`${esc}\\]8;;.*?${esc}\\\\|${esc}\\]8;;${esc}\\\\`, "g"); + const c0Start = String.fromCharCode(0x00); + const c0End = String.fromCharCode(0x1f); + const del = String.fromCharCode(0x7f); + const c1Start = String.fromCharCode(0x80); + const c1End = String.fromCharCode(0x9f); + const controlCharsRegex = new RegExp(`[${c0Start}-${c0End}${del}${c1Start}-${c1End}]`, "g"); + return value + .replace(osc8Regex, "") + .replace(ansiCsiRegex, "") + .replace(controlCharsRegex, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function resolveNextDeviceCodePollDelayMs(intervalMs: number, deadlineMs: number): number { + const remainingMs = Math.max(0, deadlineMs - Date.now()); + return Math.min(Math.max(intervalMs, OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS), remainingMs); +} + +function formatDeviceCodeError(params: { + prefix: string; + status: number; + bodyText: string; +}): string { + const body = parseJsonObject(params.bodyText); + const error = trimNonEmptyString(body?.error); + const description = trimNonEmptyString(body?.error_description); + const safeError = error ? sanitizeDeviceCodeErrorText(error) : undefined; + const safeDescription = description ? sanitizeDeviceCodeErrorText(description) : undefined; + if (safeError && safeDescription) { + return `${params.prefix}: ${safeError} (${safeDescription})`; + } + if (safeError) { + return `${params.prefix}: ${safeError}`; + } + const bodyText = sanitizeDeviceCodeErrorText(params.bodyText); + return bodyText + ? `${params.prefix}: HTTP ${params.status} ${bodyText}` + : `${params.prefix}: HTTP ${params.status}`; +} + +async function requestOpenAICodexDeviceCode(fetchFn: typeof fetch): Promise { + const response = await fetchFn(`${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/usercode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: OPENAI_CODEX_CLIENT_ID, + }), + }); + + const bodyText = await response.text(); + if (!response.ok) { + if (response.status === 404) { + throw new Error( + "OpenAI Codex device code login is not enabled for this server. Use ChatGPT OAuth instead.", + ); + } + throw new Error( + formatDeviceCodeError({ + prefix: "OpenAI device code request failed", + status: response.status, + bodyText, + }), + ); + } + + const body = parseJsonObject(bodyText) as DeviceCodeUserCodePayload | null; + const deviceAuthId = trimNonEmptyString(body?.device_auth_id); + const userCode = trimNonEmptyString(body?.user_code) ?? trimNonEmptyString(body?.usercode); + if (!deviceAuthId || !userCode) { + throw new Error("OpenAI device code response was missing the device code or user code."); + } + + return { + deviceAuthId, + userCode, + verificationUrl: `${OPENAI_AUTH_BASE_URL}/codex/device`, + intervalMs: + normalizePositiveMilliseconds(body?.interval) ?? OPENAI_CODEX_DEVICE_CODE_DEFAULT_INTERVAL_MS, + }; +} + +async function pollOpenAICodexDeviceCode(params: { + fetchFn: typeof fetch; + deviceAuthId: string; + userCode: string; + intervalMs: number; +}): Promise { + const deadline = Date.now() + OPENAI_CODEX_DEVICE_CODE_TIMEOUT_MS; + + while (Date.now() < deadline) { + const response = await params.fetchFn(`${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + device_auth_id: params.deviceAuthId, + user_code: params.userCode, + }), + }); + + const bodyText = await response.text(); + if (response.ok) { + const body = parseJsonObject(bodyText) as DeviceCodeTokenPayload | null; + const authorizationCode = trimNonEmptyString(body?.authorization_code); + const codeVerifier = trimNonEmptyString(body?.code_verifier); + if (!authorizationCode || !codeVerifier) { + throw new Error("OpenAI device authorization response was missing the exchange code."); + } + return { + authorizationCode, + codeVerifier, + }; + } + + if (response.status === 403 || response.status === 404) { + await new Promise((resolve) => + setTimeout(resolve, resolveNextDeviceCodePollDelayMs(params.intervalMs, deadline)), + ); + continue; + } + + throw new Error( + formatDeviceCodeError({ + prefix: "OpenAI device authorization failed", + status: response.status, + bodyText, + }), + ); + } + + throw new Error("OpenAI device authorization timed out after 15 minutes."); +} + +async function exchangeOpenAICodexDeviceCode(params: { + fetchFn: typeof fetch; + authorizationCode: string; + codeVerifier: string; +}): Promise { + const response = await params.fetchFn(`${OPENAI_AUTH_BASE_URL}/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: params.authorizationCode, + redirect_uri: OPENAI_CODEX_DEVICE_CALLBACK_URL, + client_id: OPENAI_CODEX_CLIENT_ID, + code_verifier: params.codeVerifier, + }), + }); + + const bodyText = await response.text(); + if (!response.ok) { + throw new Error( + formatDeviceCodeError({ + prefix: "OpenAI device token exchange failed", + status: response.status, + bodyText, + }), + ); + } + + const body = parseJsonObject(bodyText) as OAuthTokenPayload | null; + const access = trimNonEmptyString(body?.access_token); + const refresh = trimNonEmptyString(body?.refresh_token); + if (!access || !refresh) { + throw new Error("OpenAI token exchange succeeded but did not return OAuth tokens."); + } + + const expiresInMs = normalizeTokenLifetimeMs(body?.expires_in); + const expires = + expiresInMs !== undefined + ? Date.now() + expiresInMs + : (resolveCodexAccessTokenExpiry(access) ?? Date.now()); + + return { + access, + refresh, + expires, + }; +} + +export async function loginOpenAICodexDeviceCode(params: { + fetchFn?: typeof fetch; + onVerification: (prompt: OpenAICodexDeviceCodePrompt) => Promise | void; + onProgress?: (message: string) => void; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + + params.onProgress?.("Requesting device code…"); + const deviceCode = await requestOpenAICodexDeviceCode(fetchFn); + + await params.onVerification({ + verificationUrl: deviceCode.verificationUrl, + userCode: deviceCode.userCode, + expiresInMs: OPENAI_CODEX_DEVICE_CODE_TIMEOUT_MS, + }); + + params.onProgress?.("Waiting for device authorization…"); + const authorization = await pollOpenAICodexDeviceCode({ + fetchFn, + deviceAuthId: deviceCode.deviceAuthId, + userCode: deviceCode.userCode, + intervalMs: deviceCode.intervalMs, + }); + + params.onProgress?.("Exchanging device code…"); + return await exchangeOpenAICodexDeviceCode({ + fetchFn, + authorizationCode: authorization.authorizationCode, + codeVerifier: authorization.codeVerifier, + }); +} diff --git a/extensions/openai/openai-codex-provider.test.ts b/extensions/openai/openai-codex-provider.test.ts index c2bf36f4657..a31f7375e9d 100644 --- a/extensions/openai/openai-codex-provider.test.ts +++ b/extensions/openai/openai-codex-provider.test.ts @@ -5,6 +5,8 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite const refreshOpenAICodexTokenMock = vi.hoisted(() => vi.fn()); const readOpenAICodexCliOAuthProfileMock = vi.hoisted(() => vi.fn()); +const hasOpenAICodexCliOAuthCredentialMock = vi.hoisted(() => vi.fn()); +const loginOpenAICodexDeviceCodeMock = vi.hoisted(() => vi.fn()); vi.mock("./openai-codex-provider.runtime.js", () => ({ refreshOpenAICodexToken: refreshOpenAICodexTokenMock, @@ -14,10 +16,15 @@ vi.mock("./openai-codex-cli-auth.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + hasOpenAICodexCliOAuthCredential: hasOpenAICodexCliOAuthCredentialMock, readOpenAICodexCliOAuthProfile: readOpenAICodexCliOAuthProfileMock, }; }); +vi.mock("./openai-codex-device-code.js", () => ({ + loginOpenAICodexDeviceCode: loginOpenAICodexDeviceCodeMock, +})); + let buildOpenAICodexProviderPlugin: typeof import("./openai-codex-provider.js").buildOpenAICodexProviderPlugin; const tempDirs: string[] = []; @@ -61,6 +68,9 @@ describe("openai codex provider", () => { beforeEach(() => { refreshOpenAICodexTokenMock.mockReset(); readOpenAICodexCliOAuthProfileMock.mockReset(); + hasOpenAICodexCliOAuthCredentialMock.mockReset(); + hasOpenAICodexCliOAuthCredentialMock.mockReturnValue(false); + loginOpenAICodexDeviceCodeMock.mockReset(); }); afterEach(async () => { @@ -139,15 +149,198 @@ describe("openai codex provider", () => { ); }); - it("offers explicit browser and one-time Codex CLI import auth methods", () => { + it("offers OpenAI menu auth methods for login, import, and device pairing", () => { const provider = buildOpenAICodexProviderPlugin(); - expect(provider.auth?.map((method) => method.id)).toEqual(["oauth", "import-codex-cli"]); - expect(provider.auth?.find((method) => method.id === "import-codex-cli")).toMatchObject({ - label: "Import Codex CLI login", - hint: "Use existing .codex auth once", - kind: "oauth", + expect(provider.auth?.map((method) => method.id)).toEqual([ + "oauth", + "device-code", + "import-codex-cli", + ]); + expect(provider.auth?.find((method) => method.id === "oauth")).toMatchObject({ + label: "OpenAI Codex Browser Login", + hint: "Sign in with OpenAI in your browser", + wizard: { + choiceId: "openai-codex", + choiceLabel: "OpenAI Codex Browser Login", + assistantPriority: -30, + }, }); + expect(provider.auth?.find((method) => method.id === "device-code")).toMatchObject({ + label: "OpenAI Codex Device Pairing", + hint: "Pair in browser with a device code", + kind: "device_code", + wizard: { + choiceId: "openai-codex-device-code", + choiceLabel: "OpenAI Codex Device Pairing", + assistantPriority: -10, + }, + }); + expect(provider.auth?.find((method) => method.id === "import-codex-cli")).toMatchObject({ + label: "Import Existing Codex Login", + hint: "Import an existing ~/.codex login", + kind: "oauth", + wizard: { + choiceId: "openai-codex-import", + choiceLabel: "Import Existing Codex Login", + assistantPriority: -20, + assistantVisibility: "manual-only", + }, + }); + }); + + it("annotates the import option when ~/.codex auth is detected", () => { + hasOpenAICodexCliOAuthCredentialMock.mockReturnValueOnce(true); + + const provider = buildOpenAICodexProviderPlugin(); + + expect(provider.auth?.find((method) => method.id === "import-codex-cli")).toMatchObject({ + label: "Import Existing Codex Login (~/.codex detected)", + wizard: { + choiceLabel: "Import Existing Codex Login (~/.codex detected)", + assistantVisibility: "visible", + }, + }); + }); + + it("soft-fails import when no compatible ~/.codex login exists", async () => { + const provider = buildOpenAICodexProviderPlugin(); + const importMethod = provider.auth?.find((method) => method.id === "import-codex-cli"); + const note = vi.fn(async () => {}); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + readOpenAICodexCliOAuthProfileMock.mockReturnValueOnce(null); + + const result = await importMethod?.run({ + config: {}, + env: process.env, + prompter: { + note, + progress: vi.fn(), + } as never, + runtime: runtime as never, + isRemote: false, + openUrl: async () => {}, + oauth: { createVpsAwareHandlers: (() => ({})) as never }, + }); + + expect(result).toEqual({ profiles: [] }); + expect(runtime.error).toHaveBeenCalledWith( + "No compatible ~/.codex ChatGPT login found. Use Browser Login or Device Pairing instead.", + ); + expect(note).toHaveBeenCalledWith( + "No compatible ~/.codex ChatGPT login found. Use Browser Login or Device Pairing instead.", + "Import Existing Codex Login", + ); + }); + + it("stores device-code logins as OpenAI Codex oauth profiles", async () => { + const provider = buildOpenAICodexProviderPlugin(); + const deviceCodeMethod = provider.auth?.find((method) => method.id === "device-code"); + const note = vi.fn(async () => {}); + const progress = { update: vi.fn(), stop: vi.fn() }; + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + loginOpenAICodexDeviceCodeMock.mockResolvedValueOnce({ + access: + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC1kZXZpY2UtMTIzIn19.signature", + refresh: "device-refresh-token", + expires: Date.now() + 60_000, + }); + + const result = await deviceCodeMethod?.run({ + config: {}, + env: process.env, + prompter: { + note, + progress: vi.fn(() => progress), + } as never, + runtime: runtime as never, + isRemote: false, + openUrl: async () => {}, + oauth: { createVpsAwareHandlers: (() => ({})) as never }, + }); + + expect(loginOpenAICodexDeviceCodeMock).toHaveBeenCalledOnce(); + expect(runtime.error).not.toHaveBeenCalled(); + expect(note).not.toHaveBeenCalledWith( + "Trouble with device code login? See https://docs.openclaw.ai/start/faq", + "OAuth help", + ); + expect(result).toMatchObject({ + profiles: [ + { + credential: { + type: "oauth", + provider: "openai-codex", + access: + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC1kZXZpY2UtMTIzIn19.signature", + refresh: "device-refresh-token", + }, + }, + ], + defaultModel: "openai-codex/gpt-5.4", + }); + expect(result?.profiles[0]?.credential).not.toHaveProperty("idToken"); + expect(result?.profiles[0]?.credential).not.toHaveProperty("accountId"); + }); + + it("does not log the device pairing code in remote mode", async () => { + const provider = buildOpenAICodexProviderPlugin(); + const deviceCodeMethod = provider.auth?.find((method) => method.id === "device-code"); + const note = vi.fn(async () => {}); + const progress = { update: vi.fn(), stop: vi.fn() }; + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + loginOpenAICodexDeviceCodeMock.mockImplementationOnce(async ({ onVerification }) => { + await onVerification({ + verificationUrl: "https://auth.openai.com/codex/device", + userCode: "CODE-12345", + expiresInMs: 900_000, + }); + return { + access: + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjdC1kZXZpY2UtMTIzIn19.signature", + refresh: "device-refresh-token", + expires: Date.now() + 60_000, + }; + }); + + await expect( + deviceCodeMethod?.run({ + config: {}, + env: process.env, + prompter: { + note, + progress: vi.fn(() => progress), + } as never, + runtime: runtime as never, + isRemote: true, + openUrl: async () => {}, + oauth: { createVpsAwareHandlers: (() => ({})) as never }, + }), + ).resolves.toBeDefined(); + + const logOutput = runtime.log.mock.calls.flat().join("\n"); + expect(logOutput).toContain("https://auth.openai.com/codex/device"); + expect(logOutput).not.toContain("CODE-12345"); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Code: [shown on the local device only]"), + "OpenAI Codex device code", + ); + expect(note).not.toHaveBeenCalledWith( + expect.stringContaining("Code: CODE-12345"), + "OpenAI Codex device code", + ); }); it("exposes Codex CLI auth as a runtime-only external profile", () => { diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index d8297b0b1b7..49cf0f4ff45 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -24,7 +24,12 @@ import { isOpenAIApiBaseUrl, isOpenAICodexBaseUrl } from "./base-url.js"; import { OPENAI_CODEX_DEFAULT_MODEL } from "./default-models.js"; import { resolveCodexAuthIdentity } from "./openai-codex-auth-identity.js"; import { buildOpenAICodexProvider } from "./openai-codex-catalog.js"; -import { CODEX_CLI_PROFILE_ID, readOpenAICodexCliOAuthProfile } from "./openai-codex-cli-auth.js"; +import { + CODEX_CLI_PROFILE_ID, + hasOpenAICodexCliOAuthCredential, + readOpenAICodexCliOAuthProfile, +} from "./openai-codex-cli-auth.js"; +import { loginOpenAICodexDeviceCode } from "./openai-codex-device-code.js"; import { buildOpenAIResponsesProviderHooks, buildOpenAISyntheticCatalogEntry, @@ -35,6 +40,21 @@ import { const PROVIDER_ID = "openai-codex"; const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"; +const OPENAI_WIZARD_GROUP = { + groupId: "openai", + groupLabel: "OpenAI", + groupHint: "API key + Codex auth", +} as const; +const OPENAI_CODEX_LOGIN_ASSISTANT_PRIORITY = -30; +const OPENAI_CODEX_IMPORT_ASSISTANT_PRIORITY = -20; +const OPENAI_CODEX_DEVICE_PAIRING_ASSISTANT_PRIORITY = -10; +const OPENAI_CODEX_LOGIN_LABEL = "OpenAI Codex Browser Login"; +const OPENAI_CODEX_LOGIN_HINT = "Sign in with OpenAI in your browser"; +const OPENAI_CODEX_IMPORT_LABEL = "Import Existing Codex Login"; +const OPENAI_CODEX_IMPORT_HINT = "Import an existing ~/.codex login"; +const OPENAI_CODEX_IMPORT_DETECTED_SUFFIX = "~/.codex detected"; +const OPENAI_CODEX_DEVICE_PAIRING_LABEL = "OpenAI Codex Device Pairing"; +const OPENAI_CODEX_DEVICE_PAIRING_HINT = "Pair in browser with a device code"; const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4"; const OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID = "gpt-5.4-codex"; const OPENAI_CODEX_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; @@ -292,15 +312,76 @@ async function runOpenAICodexOAuth(ctx: ProviderAuthContext) { }); } +async function runOpenAICodexDeviceCode(ctx: ProviderAuthContext) { + const spin = ctx.prompter.progress("Starting device code flow…"); + try { + const creds = await loginOpenAICodexDeviceCode({ + onProgress: (message) => spin.update(message), + onVerification: async ({ verificationUrl, userCode, expiresInMs }) => { + const expiresInMinutes = Math.max(1, Math.round(expiresInMs / 60_000)); + const codeLine = ctx.isRemote + ? "Code: [shown on the local device only]" + : `Code: ${userCode}`; + await ctx.prompter.note( + [ + ctx.isRemote + ? "Open this URL in your LOCAL browser and enter the code below." + : "Open this URL in your browser and enter the code below.", + `URL: ${verificationUrl}`, + codeLine, + `Code expires in ${expiresInMinutes} minutes. Never share it.`, + ].join("\n"), + "OpenAI Codex device code", + ); + if (ctx.isRemote) { + ctx.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${verificationUrl}\n`); + return; + } + try { + await ctx.openUrl(verificationUrl); + ctx.runtime.log(`Open: ${verificationUrl}`); + } catch { + ctx.runtime.log(`Open manually: ${verificationUrl}`); + } + }, + }); + spin.stop("OpenAI device code complete"); + + const identity = resolveCodexAuthIdentity({ + accessToken: creds.access, + }); + + return buildOauthProviderAuthResult({ + providerId: PROVIDER_ID, + defaultModel: OPENAI_CODEX_DEFAULT_MODEL, + access: creds.access, + refresh: creds.refresh, + expires: creds.expires, + email: identity.email, + profileName: identity.profileName, + }); + } catch (error) { + spin.stop("OpenAI device code failed"); + ctx.runtime.error(formatErrorMessage(error)); + await ctx.prompter.note( + "Trouble with device code login? See https://docs.openclaw.ai/start/faq", + "OAuth help", + ); + throw error; + } +} + async function runImportOpenAICodexCliAuth(ctx: ProviderAuthContext) { const profile = readOpenAICodexCliOAuthProfile({ env: ctx.env ?? process.env, store: ensureAuthProfileStoreForLocalUpdate(ctx.agentDir), }); if (!profile) { - throw new Error( - "No compatible Codex CLI OAuth login found. Sign in with `codex` first or use ChatGPT OAuth instead.", - ); + const message = + "No compatible ~/.codex ChatGPT login found. Use Browser Login or Device Pairing instead."; + ctx.runtime.error(message); + await ctx.prompter.note(message, OPENAI_CODEX_IMPORT_LABEL); + return { profiles: [] }; } return { @@ -344,7 +425,16 @@ function buildOpenAICodexAuthDoctorHint(ctx: { profileId?: string }) { return "Deprecated profile. Run `openclaw models auth login --provider openai-codex` or `openclaw configure`."; } +function buildOpenAICodexImportWizardLabel(hasCodexCliCredential: boolean) { + if (!hasCodexCliCredential) { + return OPENAI_CODEX_IMPORT_LABEL; + } + return `${OPENAI_CODEX_IMPORT_LABEL} (${OPENAI_CODEX_IMPORT_DETECTED_SUFFIX})`; +} + export function buildOpenAICodexProviderPlugin(): ProviderPlugin { + const hasCodexCliCredential = hasOpenAICodexCliOAuthCredential(); + const importWizardLabel = buildOpenAICodexImportWizardLabel(hasCodexCliCredential); return { id: PROVIDER_ID, label: "OpenAI Codex", @@ -352,27 +442,54 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { auth: [ { id: "oauth", - label: "ChatGPT OAuth", - hint: "Browser sign-in", + label: OPENAI_CODEX_LOGIN_LABEL, + hint: OPENAI_CODEX_LOGIN_HINT, kind: "oauth", + wizard: { + choiceId: "openai-codex", + choiceLabel: OPENAI_CODEX_LOGIN_LABEL, + choiceHint: OPENAI_CODEX_LOGIN_HINT, + assistantPriority: OPENAI_CODEX_LOGIN_ASSISTANT_PRIORITY, + ...OPENAI_WIZARD_GROUP, + }, run: async (ctx) => await runOpenAICodexOAuth(ctx), }, + { + id: "device-code", + label: OPENAI_CODEX_DEVICE_PAIRING_LABEL, + hint: OPENAI_CODEX_DEVICE_PAIRING_HINT, + kind: "device_code", + wizard: { + choiceId: "openai-codex-device-code", + choiceLabel: OPENAI_CODEX_DEVICE_PAIRING_LABEL, + choiceHint: OPENAI_CODEX_DEVICE_PAIRING_HINT, + assistantPriority: OPENAI_CODEX_DEVICE_PAIRING_ASSISTANT_PRIORITY, + ...OPENAI_WIZARD_GROUP, + }, + run: async (ctx) => { + try { + return await runOpenAICodexDeviceCode(ctx); + } catch { + return { profiles: [] }; + } + }, + }, { id: "import-codex-cli", - label: "Import Codex CLI login", - hint: "Use existing .codex auth once", + label: importWizardLabel, + hint: OPENAI_CODEX_IMPORT_HINT, kind: "oauth", + wizard: { + choiceId: "openai-codex-import", + choiceLabel: importWizardLabel, + choiceHint: OPENAI_CODEX_IMPORT_HINT, + assistantPriority: OPENAI_CODEX_IMPORT_ASSISTANT_PRIORITY, + assistantVisibility: hasCodexCliCredential ? "visible" : "manual-only", + ...OPENAI_WIZARD_GROUP, + }, run: async (ctx) => await runImportOpenAICodexCliAuth(ctx), }, ], - wizard: { - setup: { - choiceId: "openai-codex", - choiceLabel: "OpenAI Codex (ChatGPT OAuth)", - choiceHint: "Browser sign-in", - methodId: "oauth", - }, - }, catalog: { order: "profile", run: async (ctx) => { diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 13c496dbc46..6e4b5405cd7 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -185,8 +185,8 @@ export function buildOpenAIProvider(): ProviderPlugin { createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key", - label: "OpenAI API key", - hint: "Direct OpenAI API key", + label: "OpenAI API Key", + hint: "Use your OpenAI API key directly", optionKey: "openaiApiKey", flagName: "--openai-api-key", envVar: "OPENAI_API_KEY", @@ -196,10 +196,10 @@ export function buildOpenAIProvider(): ProviderPlugin { applyConfig: (cfg) => applyOpenAIConfig(cfg), wizard: { choiceId: "openai-api-key", - choiceLabel: "OpenAI API key", + choiceLabel: "OpenAI API Key", groupId: "openai", groupLabel: "OpenAI", - groupHint: "Codex OAuth + API key", + groupHint: "API key + Codex auth", }, }), ], diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 1ed00df3455..cf049d43267 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -15,24 +15,48 @@ "method": "oauth", "choiceId": "openai-codex", "deprecatedChoiceIds": ["codex-cli"], - "choiceLabel": "OpenAI Codex (ChatGPT OAuth)", - "choiceHint": "Browser sign-in", + "choiceLabel": "OpenAI Codex Browser Login", + "choiceHint": "Sign in with OpenAI in your browser", + "assistantPriority": -30, "groupId": "openai", "groupLabel": "OpenAI", - "groupHint": "Codex OAuth + API key" + "groupHint": "API key + Codex auth" + }, + { + "provider": "openai-codex", + "method": "device-code", + "choiceId": "openai-codex-device-code", + "choiceLabel": "OpenAI Codex Device Pairing", + "choiceHint": "Pair in browser with a device code", + "assistantPriority": -10, + "groupId": "openai", + "groupLabel": "OpenAI", + "groupHint": "API key + Codex auth" + }, + { + "provider": "openai-codex", + "method": "import-codex-cli", + "choiceId": "openai-codex-import", + "choiceLabel": "Import Existing Codex Login", + "choiceHint": "Import an existing ~/.codex login", + "assistantPriority": -20, + "groupId": "openai", + "groupLabel": "OpenAI", + "groupHint": "API key + Codex auth" }, { "provider": "openai", "method": "api-key", "choiceId": "openai-api-key", - "choiceLabel": "OpenAI API key", + "choiceLabel": "OpenAI API Key", + "assistantPriority": -40, "groupId": "openai", "groupLabel": "OpenAI", - "groupHint": "Codex OAuth + API key", + "groupHint": "API key + Codex auth", "optionKey": "openaiApiKey", "cliFlag": "--openai-api-key", "cliOption": "--openai-api-key ", - "cliDescription": "OpenAI API key" + "cliDescription": "OpenAI API Key" } ], "contracts": { diff --git a/extensions/openai/provider-contract-api.ts b/extensions/openai/provider-contract-api.ts index fbfdac6f459..ec492daf5af 100644 --- a/extensions/openai/provider-contract-api.ts +++ b/extensions/openai/provider-contract-api.ts @@ -1,6 +1,11 @@ import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; const noopAuth = async () => ({ profiles: [] }); +const OPENAI_WIZARD_GROUP = { + groupId: "openai", + groupLabel: "OpenAI", + groupHint: "API key + Codex auth", +} as const; export function createOpenAICodexProvider(): ProviderPlugin { return { @@ -11,19 +16,46 @@ export function createOpenAICodexProvider(): ProviderPlugin { { id: "oauth", kind: "oauth", - label: "ChatGPT OAuth", - hint: "Browser sign-in", + label: "OpenAI Codex Browser Login", + hint: "Sign in with OpenAI in your browser", run: noopAuth, + wizard: { + choiceId: "openai-codex", + choiceLabel: "OpenAI Codex Browser Login", + choiceHint: "Sign in with OpenAI in your browser", + assistantPriority: -30, + ...OPENAI_WIZARD_GROUP, + }, + }, + { + id: "device-code", + kind: "device_code", + label: "OpenAI Codex Device Pairing", + hint: "Pair in browser with a device code", + run: noopAuth, + wizard: { + choiceId: "openai-codex-device-code", + choiceLabel: "OpenAI Codex Device Pairing", + choiceHint: "Pair in browser with a device code", + assistantPriority: -10, + ...OPENAI_WIZARD_GROUP, + }, + }, + { + id: "import-codex-cli", + kind: "oauth", + label: "Import Existing Codex Login", + hint: "Import an existing ~/.codex login", + run: noopAuth, + wizard: { + choiceId: "openai-codex-import", + choiceLabel: "Import Existing Codex Login", + choiceHint: "Import an existing ~/.codex login", + assistantPriority: -20, + ...OPENAI_WIZARD_GROUP, + }, }, ], - wizard: { - setup: { - choiceId: "openai-codex", - choiceLabel: "OpenAI Codex (ChatGPT OAuth)", - choiceHint: "Browser sign-in", - methodId: "oauth", - }, - }, }; } @@ -38,15 +70,14 @@ export function createOpenAIProvider(): ProviderPlugin { { id: "api-key", kind: "api_key", - label: "OpenAI API key", - hint: "Direct OpenAI API key", + label: "OpenAI API Key", + hint: "Use your OpenAI API key directly", run: noopAuth, wizard: { choiceId: "openai-api-key", - choiceLabel: "OpenAI API key", - groupId: "openai", - groupLabel: "OpenAI", - groupHint: "Codex OAuth + API key", + choiceLabel: "OpenAI API Key", + assistantPriority: -40, + ...OPENAI_WIZARD_GROUP, }, }, ], diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index d8751c82726..88734f3e9b4 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -447,6 +447,53 @@ describe("buildAuthChoiceOptions", () => { ]); }); + it("orders OpenAI auth methods as api key, login, import, then device pairing", () => { + resolveProviderWizardOptions.mockReturnValue([ + { + value: "openai-api-key", + label: "OpenAI API Key", + groupId: "openai", + groupLabel: "OpenAI", + assistantPriority: -40, + }, + { + value: "openai-codex", + label: "OpenAI Codex Browser Login", + groupId: "openai", + groupLabel: "OpenAI", + assistantPriority: -30, + }, + { + value: "openai-codex-import", + label: "Import Existing Codex Login (~/.codex detected)", + groupId: "openai", + groupLabel: "OpenAI", + assistantPriority: -20, + }, + { + value: "openai-codex-device-code", + label: "OpenAI Codex Device Pairing", + groupId: "openai", + groupLabel: "OpenAI", + assistantPriority: -10, + }, + ]); + + const { groups } = buildAuthChoiceGroups({ + store: EMPTY_STORE, + includeSkip: false, + }); + const openAIGroup = groups.find((group) => group.value === "openai"); + + expect(openAIGroup).toBeDefined(); + expect(openAIGroup?.options.map((option) => option.value)).toEqual([ + "openai-api-key", + "openai-codex", + "openai-codex-import", + "openai-codex-device-code", + ]); + }); + it("groups OpenCode Zen and Go under one OpenCode entry", () => { resolveManifestProviderAuthChoices.mockReturnValue([ {