diff --git a/CHANGELOG.md b/CHANGELOG.md index b56d02a734b..143bfe8ed26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,7 @@ Docs: https://docs.openclaw.ai - Terminal/logging: optimize `sanitizeForLog()` by replacing the iterative control-character stripping loop with a single regex pass while preserving the existing ANSI-first sanitization behavior. (#67205) Thanks @bulutmuf. - QA/CI: make `openclaw qa suite` and `openclaw qa telegram` fail by default when scenarios fail, add `--allow-failures` for artifact-only runs, and tighten live-lane defaults for CI automation. (#69122) Thanks @joshavant. - Mattermost: stream thinking, tool activity, and partial reply text into a single draft preview post that finalizes in place when safe. (#47838) thanks @ninjaa. +- 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. ### Fixes @@ -184,17 +185,6 @@ Docs: https://docs.openclaw.ai - Control UI/device pairing: explain scope and role approval upgrades during reconnects, and show requested versus approved access in the Control UI and `openclaw devices` so broader reconnects no longer look like lost pairings. (#69221) Thanks @obviyus. - Gateway/Control UI: surface pending scope, role, and device-metadata pairing approvals in auth errors and Control UI hints so broader reconnects no longer look like random auth breakage. (#69226) Thanks @obviyus. -## 2026.4.19-beta.2 - -### Fixes - -- Agents/openai-completions: always send `stream_options.include_usage` on streaming requests, so local and custom OpenAI-compatible backends report real context usage instead of showing 0%. (#68746) Thanks @kagura-agent. -- Agents/nested lanes: scope nested agent work per target session so a long-running nested run on one session no longer head-of-line blocks unrelated sessions across the gateway. (#67785) Thanks @stainlu. -- Agents/status: preserve carried-forward session token totals for providers that omit usage metadata, so `/status` and `openclaw sessions` keep showing the last known context usage instead of dropping back to unknown/0%. (#67695) Thanks @stainlu. -- Install/update: keep legacy update verification compatible with the QA Lab runtime shim, so updating older global installs to beta no longer fails after npm installs the package successfully. - -## 2026.4.19-beta.1 - ### Fixes - Agents/channels: route cross-agent subagent spawns through the target agent's bound channel account while preserving peer and workspace/role-scoped bindings, so child sessions no longer inherit the caller's account in shared rooms, workspaces, or multi-account setups. (#67508) Thanks @lukeboyett and @gumadeiras. diff --git a/extensions/openai/openai-codex-auth-identity.test.ts b/extensions/openai/openai-codex-auth-identity.test.ts index 21be7032d9f..bffb344e48f 100644 --- a/extensions/openai/openai-codex-auth-identity.test.ts +++ b/extensions/openai/openai-codex-auth-identity.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveCodexAuthIdentity } from "./openai-codex-auth-identity.js"; +import { + resolveCodexAuthIdentity, + resolveCodexChatgptAccountId, +} from "./openai-codex-auth-identity.js"; function createJwt(payload: Record): string { const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); @@ -54,3 +57,21 @@ describe("resolveCodexAuthIdentity", () => { expect(resolveCodexAuthIdentity({ accessToken: "not-a-jwt-token" })).toEqual({}); }); }); + +describe("resolveCodexChatgptAccountId", () => { + it("extracts the ChatGPT account id from the auth claim", () => { + expect( + resolveCodexChatgptAccountId( + createJwt({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct_123", + }, + }), + ), + ).toBe("acct_123"); + }); + + it("returns undefined when the account id is missing", () => { + expect(resolveCodexChatgptAccountId(createJwt({}))).toBeUndefined(); + }); +}); diff --git a/extensions/openai/openai-codex-auth-identity.ts b/extensions/openai/openai-codex-auth-identity.ts index b43fa481316..21e0632b247 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; @@ -59,6 +60,11 @@ export function resolveCodexStableSubject(payload: CodexJwtPayload | null): stri return sub; } +export function resolveCodexChatgptAccountId(token: string): string | undefined { + const auth = decodeCodexJwtPayload(token)?.["https://api.openai.com/auth"]; + return trimNonEmptyString(auth?.chatgpt_account_id); +} + export function resolveCodexAccessTokenExpiry(accessToken: string): number | undefined { const payload = decodeCodexJwtPayload(accessToken); const exp = normalizeFutureEpochSeconds(payload?.exp); 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..4dbb4e8a2dc --- /dev/null +++ b/extensions/openai/openai-codex-device-code.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from "vitest"; +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 () => { + 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 credentials = await loginOpenAICodexDeviceCode({ + fetchFn: fetchMock as typeof fetch, + onVerification, + onProgress, + }); + + 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", + accountId: "acct_123", + idToken: expect.any(String), + }); + expect(credentials.expires).toBeGreaterThan(Date.now()); + }); + + it("surfaces user-code request failures", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 503 })); + + await expect( + loginOpenAICodexDeviceCode({ + fetchFn: fetchMock as typeof fetch, + onVerification: async () => {}, + }), + ).rejects.toThrow("OpenAI device code request failed: HTTP 503"); + }); + + it("surfaces device authorization failures with 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", + error_description: "Denied", + }, + { status: 401 }, + ), + ); + + await expect( + loginOpenAICodexDeviceCode({ + fetchFn: fetchMock as typeof fetch, + onVerification: async () => {}, + }), + ).rejects.toThrow("OpenAI device authorization failed: authorization_declined (Denied)"); + }); +}); diff --git a/extensions/openai/openai-codex-device-code.ts b/extensions/openai/openai-codex-device-code.ts new file mode 100644 index 00000000000..436ed7fbba7 --- /dev/null +++ b/extensions/openai/openai-codex-device-code.ts @@ -0,0 +1,289 @@ +import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env"; +import { + resolveCodexAccessTokenExpiry, + resolveCodexChatgptAccountId, +} 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_CALLBACK_URL = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`; + +type OpenAICodexDeviceCodePrompt = { + verificationUrl: string; + userCode: string; + expiresInMs: number; +}; + +type OpenAICodexDeviceCodeCredentials = { + access: string; + refresh: string; + expires: number; + accountId?: string; + idToken?: string; +}; + +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; + id_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())) { + return Number.parseInt(value.trim(), 10) * 1000; + } + 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 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); + if (error && description) { + return `${params.prefix}: ${error} (${description})`; + } + if (error) { + return `${params.prefix}: ${error}`; + } + const bodyText = params.bodyText.trim(); + 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, Math.max(0, Math.min(params.intervalMs, deadline - Date.now()))), + ); + 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); + const idToken = trimNonEmptyString(body?.id_token); + if (!access || !refresh) { + throw new Error("OpenAI token exchange succeeded but did not return OAuth tokens."); + } + + const expires = + Date.now() + + (normalizeTokenLifetimeMs(body?.expires_in) ?? resolveCodexAccessTokenExpiry(access) ?? 0); + const accountId = + resolveCodexChatgptAccountId(access) ?? (idToken && resolveCodexChatgptAccountId(idToken)); + + return { + access, + refresh, + expires, + ...(accountId ? { accountId } : {}), + ...(idToken ? { idToken } : {}), + }; +} + +export async function loginOpenAICodexDeviceCode(params: { + fetchFn?: typeof fetch; + onVerification: (prompt: OpenAICodexDeviceCodePrompt) => Promise | void; + onProgress?: (message: string) => void; +}): Promise { + ensureGlobalUndiciEnvProxyDispatcher(); + 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..ed73b7d5083 100644 --- a/extensions/openai/openai-codex-provider.test.ts +++ b/extensions/openai/openai-codex-provider.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite const refreshOpenAICodexTokenMock = vi.hoisted(() => vi.fn()); const readOpenAICodexCliOAuthProfileMock = vi.hoisted(() => vi.fn()); +const loginOpenAICodexDeviceCodeMock = vi.hoisted(() => vi.fn()); vi.mock("./openai-codex-provider.runtime.js", () => ({ refreshOpenAICodexToken: refreshOpenAICodexTokenMock, @@ -18,6 +19,10 @@ vi.mock("./openai-codex-cli-auth.js", async (importOriginal) => { }; }); +vi.mock("./openai-codex-device-code.js", () => ({ + loginOpenAICodexDeviceCode: loginOpenAICodexDeviceCodeMock, +})); + let buildOpenAICodexProviderPlugin: typeof import("./openai-codex-provider.js").buildOpenAICodexProviderPlugin; const tempDirs: string[] = []; @@ -61,6 +66,7 @@ describe("openai codex provider", () => { beforeEach(() => { refreshOpenAICodexTokenMock.mockReset(); readOpenAICodexCliOAuthProfileMock.mockReset(); + loginOpenAICodexDeviceCodeMock.mockReset(); }); afterEach(async () => { @@ -139,10 +145,22 @@ describe("openai codex provider", () => { ); }); - it("offers explicit browser and one-time Codex CLI import auth methods", () => { + it("offers browser, device-code, and one-time Codex CLI import auth methods", () => { const provider = buildOpenAICodexProviderPlugin(); - expect(provider.auth?.map((method) => method.id)).toEqual(["oauth", "import-codex-cli"]); + expect(provider.auth?.map((method) => method.id)).toEqual([ + "oauth", + "device-code", + "import-codex-cli", + ]); + expect(provider.auth?.find((method) => method.id === "device-code")).toMatchObject({ + label: "ChatGPT device code", + hint: "Browser device-code sign-in", + kind: "device_code", + wizard: { + choiceId: "openai-codex-device-code", + }, + }); expect(provider.auth?.find((method) => method.id === "import-codex-cli")).toMatchObject({ label: "Import Codex CLI login", hint: "Use existing .codex auth once", @@ -150,6 +168,62 @@ describe("openai codex provider", () => { }); }); + 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, + accountId: "acct-device-123", + idToken: "device-id-token", + }); + + 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", + accountId: "acct-device-123", + idToken: "device-id-token", + }, + }, + ], + defaultModel: "openai-codex/gpt-5.4", + }); + }); + it("exposes Codex CLI auth as a runtime-only external profile", () => { const provider = buildOpenAICodexProviderPlugin(); const credential = { diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index d8297b0b1b7..47f342a7b29 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -24,7 +24,13 @@ 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 { trimNonEmptyString } from "./openai-codex-shared.js"; import { buildOpenAIResponsesProviderHooks, buildOpenAISyntheticCatalogEntry, @@ -35,6 +41,18 @@ 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 Login"; +const OPENAI_CODEX_IMPORT_LABEL = "OpenAI Codex"; +const OPENAI_CODEX_IMPORT_DETECTED_SUFFIX = "~/.codex existing key detected"; +const OPENAI_CODEX_DEVICE_PAIRING_LABEL = "OpenAI Codex Device Pairing"; 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,6 +310,68 @@ 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)); + 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}`, + `Code: ${userCode}`, + `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\nEnter this code:\n\n${userCode}\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, + credentialExtra: { + ...(trimNonEmptyString(creds.accountId) ? { accountId: creds.accountId } : {}), + ...(trimNonEmptyString(creds.idToken) ? { idToken: creds.idToken } : {}), + }, + }); + } 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, @@ -344,7 +424,15 @@ function buildOpenAICodexAuthDoctorHint(ctx: { profileId?: string }) { return "Deprecated profile. Run `openclaw models auth login --provider openai-codex` or `openclaw configure`."; } +function buildOpenAICodexImportWizardLabel() { + if (!hasOpenAICodexCliOAuthCredential()) { + return OPENAI_CODEX_IMPORT_LABEL; + } + return `${OPENAI_CODEX_IMPORT_LABEL} (${OPENAI_CODEX_IMPORT_DETECTED_SUFFIX})`; +} + export function buildOpenAICodexProviderPlugin(): ProviderPlugin { + const importWizardLabel = buildOpenAICodexImportWizardLabel(); return { id: PROVIDER_ID, label: "OpenAI Codex", @@ -352,27 +440,53 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { auth: [ { id: "oauth", - label: "ChatGPT OAuth", + label: OPENAI_CODEX_LOGIN_LABEL, hint: "Browser sign-in", kind: "oauth", + wizard: { + choiceId: "openai-codex", + choiceLabel: OPENAI_CODEX_LOGIN_LABEL, + choiceHint: "Browser sign-in", + 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: "Pair in browser with a device code", + kind: "device_code", + wizard: { + choiceId: "openai-codex-device-code", + choiceLabel: OPENAI_CODEX_DEVICE_PAIRING_LABEL, + choiceHint: "Pair in browser with a device code", + 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: "Import existing ~/.codex login once", kind: "oauth", + wizard: { + choiceId: "openai-codex-import", + choiceLabel: importWizardLabel, + choiceHint: "Import existing ~/.codex login once", + assistantPriority: OPENAI_CODEX_IMPORT_ASSISTANT_PRIORITY, + ...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/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 1ed00df3455..eae4448403d 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -21,6 +21,16 @@ "groupLabel": "OpenAI", "groupHint": "Codex OAuth + API key" }, + { + "provider": "openai-codex", + "method": "device-code", + "choiceId": "openai-codex-device-code", + "choiceLabel": "OpenAI Codex (device code)", + "choiceHint": "Browser device-code sign-in", + "groupId": "openai", + "groupLabel": "OpenAI", + "groupHint": "Codex OAuth + API key" + }, { "provider": "openai", "method": "api-key", diff --git a/extensions/openai/provider-contract-api.ts b/extensions/openai/provider-contract-api.ts index fbfdac6f459..106235c5ffb 100644 --- a/extensions/openai/provider-contract-api.ts +++ b/extensions/openai/provider-contract-api.ts @@ -14,16 +14,31 @@ export function createOpenAICodexProvider(): ProviderPlugin { label: "ChatGPT OAuth", hint: "Browser sign-in", run: noopAuth, + wizard: { + choiceId: "openai-codex", + choiceLabel: "OpenAI Codex (ChatGPT OAuth)", + choiceHint: "Browser sign-in", + groupId: "openai", + groupLabel: "OpenAI", + groupHint: "Codex OAuth + API key", + }, + }, + { + id: "device-code", + kind: "device_code", + label: "ChatGPT device code", + hint: "Browser device-code sign-in", + run: noopAuth, + wizard: { + choiceId: "openai-codex-device-code", + choiceLabel: "OpenAI Codex (device code)", + choiceHint: "Browser device-code sign-in", + groupId: "openai", + groupLabel: "OpenAI", + groupHint: "Codex OAuth + API key", + }, }, ], - wizard: { - setup: { - choiceId: "openai-codex", - choiceLabel: "OpenAI Codex (ChatGPT OAuth)", - choiceHint: "Browser sign-in", - methodId: "oauth", - }, - }, }; }