From 17c1e13d3265acd8150a6fbe8c53ce8edca34333 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 20 Apr 2026 20:10:58 -0700 Subject: [PATCH] feat(openai): group codex auth under OpenAI setup --- .../openai/openai-codex-cli-auth.test.ts | 15 ++++++ extensions/openai/openai-codex-cli-auth.ts | 12 +++++ .../openai/openai-codex-provider.test.ts | 43 +++++++++++++++-- extensions/openai/openclaw.plugin.json | 26 +++++++--- extensions/openai/provider-contract-api.ts | 46 ++++++++++++------ src/commands/auth-choice-options.test.ts | 47 +++++++++++++++++++ 6 files changed, 163 insertions(+), 26 deletions(-) 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-provider.test.ts b/extensions/openai/openai-codex-provider.test.ts index ed73b7d5083..27fc6f652f6 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 hasOpenAICodexCliOAuthCredentialMock = vi.hoisted(() => vi.fn()); const loginOpenAICodexDeviceCodeMock = vi.hoisted(() => vi.fn()); vi.mock("./openai-codex-provider.runtime.js", () => ({ @@ -15,6 +16,7 @@ vi.mock("./openai-codex-cli-auth.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + hasOpenAICodexCliOAuthCredential: hasOpenAICodexCliOAuthCredentialMock, readOpenAICodexCliOAuthProfile: readOpenAICodexCliOAuthProfileMock, }; }); @@ -66,6 +68,8 @@ describe("openai codex provider", () => { beforeEach(() => { refreshOpenAICodexTokenMock.mockReset(); readOpenAICodexCliOAuthProfileMock.mockReset(); + hasOpenAICodexCliOAuthCredentialMock.mockReset(); + hasOpenAICodexCliOAuthCredentialMock.mockReturnValue(false); loginOpenAICodexDeviceCodeMock.mockReset(); }); @@ -145,7 +149,7 @@ describe("openai codex provider", () => { ); }); - it("offers browser, device-code, 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([ @@ -153,18 +157,47 @@ describe("openai codex provider", () => { "device-code", "import-codex-cli", ]); + expect(provider.auth?.find((method) => method.id === "oauth")).toMatchObject({ + label: "OpenAI Codex Login", + hint: "Browser sign-in", + wizard: { + choiceId: "openai-codex", + choiceLabel: "OpenAI Codex Login", + assistantPriority: -30, + }, + }); expect(provider.auth?.find((method) => method.id === "device-code")).toMatchObject({ - label: "ChatGPT device code", - hint: "Browser device-code sign-in", + 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 Codex CLI login", - hint: "Use existing .codex auth once", + label: "OpenAI Codex", + hint: "Import existing ~/.codex login once", kind: "oauth", + wizard: { + choiceId: "openai-codex-import", + choiceLabel: "OpenAI Codex", + assistantPriority: -20, + }, + }); + }); + + 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: "OpenAI Codex (~/.codex existing key detected)", + wizard: { + choiceLabel: "OpenAI Codex (~/.codex existing key detected)", + }, }); }); diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index eae4448403d..5def6f714c3 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -15,30 +15,44 @@ "method": "oauth", "choiceId": "openai-codex", "deprecatedChoiceIds": ["codex-cli"], - "choiceLabel": "OpenAI Codex (ChatGPT OAuth)", + "choiceLabel": "OpenAI Codex Login", "choiceHint": "Browser sign-in", + "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 code)", - "choiceHint": "Browser device-code sign-in", + "choiceLabel": "OpenAI Codex Device Pairing", + "choiceHint": "Pair in browser with a device code", + "assistantPriority": -10, "groupId": "openai", "groupLabel": "OpenAI", - "groupHint": "Codex OAuth + API key" + "groupHint": "API key + Codex auth" + }, + { + "provider": "openai-codex", + "method": "import-codex-cli", + "choiceId": "openai-codex-import", + "choiceLabel": "OpenAI Codex", + "choiceHint": "Import existing ~/.codex login once", + "assistantPriority": -20, + "groupId": "openai", + "groupLabel": "OpenAI", + "groupHint": "API key + Codex auth" }, { "provider": "openai", "method": "api-key", "choiceId": "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 ", diff --git a/extensions/openai/provider-contract-api.ts b/extensions/openai/provider-contract-api.ts index 106235c5ffb..f208fd42571 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,31 +16,43 @@ export function createOpenAICodexProvider(): ProviderPlugin { { id: "oauth", kind: "oauth", - label: "ChatGPT OAuth", + label: "OpenAI Codex Login", hint: "Browser sign-in", run: noopAuth, wizard: { choiceId: "openai-codex", - choiceLabel: "OpenAI Codex (ChatGPT OAuth)", + choiceLabel: "OpenAI Codex Login", choiceHint: "Browser sign-in", - groupId: "openai", - groupLabel: "OpenAI", - groupHint: "Codex OAuth + API key", + assistantPriority: -30, + ...OPENAI_WIZARD_GROUP, }, }, { id: "device-code", kind: "device_code", - label: "ChatGPT device code", - hint: "Browser device-code sign-in", + 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 code)", - choiceHint: "Browser device-code sign-in", - groupId: "openai", - groupLabel: "OpenAI", - groupHint: "Codex OAuth + API key", + 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: "OpenAI Codex", + hint: "Import existing ~/.codex login once", + run: noopAuth, + wizard: { + choiceId: "openai-codex-import", + choiceLabel: "OpenAI Codex", + choiceHint: "Import existing ~/.codex login once", + assistantPriority: -20, + ...OPENAI_WIZARD_GROUP, }, }, ], @@ -59,9 +76,8 @@ export function createOpenAIProvider(): ProviderPlugin { wizard: { choiceId: "openai-api-key", choiceLabel: "OpenAI API key", - groupId: "openai", - groupLabel: "OpenAI", - groupHint: "Codex OAuth + 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..648d6dcad8e 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 Login", + groupId: "openai", + groupLabel: "OpenAI", + assistantPriority: -30, + }, + { + value: "openai-codex-import", + label: "OpenAI Codex (~/.codex existing key 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([ {