From cbd3ee0e63d0fde21025a0a72a6b3126b0fa21ca Mon Sep 17 00:00:00 2001 From: Ouyang Xingyuan Date: Sat, 25 Jul 2026 17:57:42 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(gui):=20restore=20OpenAI=20account=20se?= =?UTF-8?q?tup=20paths=EF=BC=88=E6=81=A2=E5=A4=8D=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原因: - OpenAI account credentials can outlive the built-in provider, leaving no GUI path to restore routing. - Disabled legacy or API-key OpenAI rows must not be treated as canonical Codex account providers. - Clash fake-IP DNS can reject the exact canonical OpenAI preset after canonical seed validation. 改动: - Keep OpenAI visible in the unified account picker and add in-place recovery on Codex Auth. - Create missing canonical providers, re-enable only canonical disabled providers, and reject noncanonical rows without overwriting them. - Preserve destination checks for custom providers and cover provider-state, banner-state, test isolation, and fake-IP boundaries. --- gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/pages/CodexAuth.tsx | 89 +++++++++++++------ gui/src/pages/Providers.tsx | 29 ++++-- gui/src/provider-payload.ts | 67 ++++++++++++-- gui/tests/codex-auth-provider-enable.test.tsx | 52 +++++++++++ src/server/management/provider-routes.ts | 4 +- tests/management-provider-validation.test.ts | 48 +++++++++- tests/provider-payload.test.ts | 83 +++++++++++++++++ 13 files changed, 351 insertions(+), 39 deletions(-) create mode 100644 gui/tests/codex-auth-provider-enable.test.tsx diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 57024d2fff..11d0ba8a80 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -610,6 +610,9 @@ export const de: Record = { "codexAuth.accountModeDirectDesc": "Anfragen verwenden nur die Hauptanmeldung; hinzugefügte Konten bleiben für den Pool-Modus gespeichert.", "codexAuth.openaiMissing": "Der integrierte OpenAI-Anbieter ist nicht konfiguriert.", "codexAuth.openaiDisabled": "Der integrierte OpenAI-Anbieter ist deaktiviert.", + "codexAuth.openaiUnavailableDesc": "Deine OpenAI-Konten sind weiterhin verfügbar. Aktiviere den Anbieter, um Codex-Anfragen weiterzuleiten.", + "codexAuth.enableOpenai": "OpenAI aktivieren", + "codexAuth.enablingOpenai": "Wird aktiviert...", "codexAuth.openProviders": "Anbieter öffnen", "codexAuth.add": "Hinzufügen", "codexAuth.refreshQuota": "Kontingente aktualisieren", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 2f9b40fe3f..9cb94f08b6 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -908,6 +908,9 @@ export const en = { "codexAuth.accountModeDirectDesc": "Requests use only the main login; added accounts remain stored for Pool mode.", "codexAuth.openaiMissing": "The built-in OpenAI provider is not configured.", "codexAuth.openaiDisabled": "The built-in OpenAI provider is disabled.", + "codexAuth.openaiUnavailableDesc": "Your OpenAI accounts are still available. Enable the provider to route Codex requests.", + "codexAuth.enableOpenai": "Enable OpenAI", + "codexAuth.enablingOpenai": "Enabling...", "codexAuth.openProviders": "Open Providers", "codexAuth.add": "Add", "codexAuth.refreshQuota": "Refresh quotas", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 93e68fd825..80c076fe67 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -862,6 +862,9 @@ export const ja: Record = { "codexAuth.accountModeDirectDesc": "リクエストはメインログインのみを使用します; 追加アカウントはプールモード用に保持されます。", "codexAuth.openaiMissing": "組み込みの OpenAI プロバイダーが設定されていません。", "codexAuth.openaiDisabled": "組み込みの OpenAI プロバイダーが無効です。", + "codexAuth.openaiUnavailableDesc": "OpenAI アカウントは引き続き利用できます。Codex リクエストをルーティングするにはプロバイダーを有効にしてください。", + "codexAuth.enableOpenai": "OpenAI を有効にする", + "codexAuth.enablingOpenai": "有効化中...", "codexAuth.openProviders": "プロバイダーを開く", "codexAuth.add": "追加", "codexAuth.refreshQuota": "クォータを更新", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 563baa3144..90e05b8aec 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -627,6 +627,9 @@ export const ko: Record = { "codexAuth.accountModeDirectDesc": "요청은 메인 로그인만 사용하며, 추가 계정은 풀 모드용으로 계속 저장됩니다.", "codexAuth.openaiMissing": "내장 OpenAI 프로바이더가 설정되지 않았습니다.", "codexAuth.openaiDisabled": "내장 OpenAI 프로바이더가 비활성화되어 있습니다.", + "codexAuth.openaiUnavailableDesc": "OpenAI 계정은 그대로 사용할 수 있습니다. Codex 요청을 라우팅하려면 프로바이더를 활성화하세요.", + "codexAuth.enableOpenai": "OpenAI 활성화", + "codexAuth.enablingOpenai": "활성화 중...", "codexAuth.openProviders": "프로바이더 열기", "codexAuth.add": "추가", "codexAuth.refreshQuota": "할당량 새로고침", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 990031214c..98809fefb9 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -907,6 +907,9 @@ export const ru: Record = { "codexAuth.accountModeDirectDesc": "Запросы используют только основной вход; добавленные аккаунты сохраняются для режима пула.", "codexAuth.openaiMissing": "Встроенный провайдер OpenAI не настроен.", "codexAuth.openaiDisabled": "Встроенный провайдер OpenAI отключён.", + "codexAuth.openaiUnavailableDesc": "Ваши аккаунты OpenAI по-прежнему доступны. Включите провайдера для маршрутизации запросов Codex.", + "codexAuth.enableOpenai": "Включить OpenAI", + "codexAuth.enablingOpenai": "Включение...", "codexAuth.openProviders": "Открыть провайдеров", "codexAuth.add": "Добавить", "codexAuth.refreshQuota": "Обновить квоты", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3935c3f749..30896111cf 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -627,6 +627,9 @@ export const zh: Record = { "codexAuth.accountModeDirectDesc": "请求仅使用主登录;已添加账户会继续存储,供账户池模式使用。", "codexAuth.openaiMissing": "未配置内置 OpenAI 提供方。", "codexAuth.openaiDisabled": "内置 OpenAI 提供方已禁用。", + "codexAuth.openaiUnavailableDesc": "你的 OpenAI 账号仍然可用。启用提供方后即可路由 Codex 请求。", + "codexAuth.enableOpenai": "启用 OpenAI", + "codexAuth.enablingOpenai": "正在启用...", "codexAuth.openProviders": "打开提供商", "codexAuth.add": "添加", "codexAuth.refreshQuota": "刷新额度", diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index 4ee6d9a44c..f158ffbad0 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -2,6 +2,44 @@ import { useCallback, useEffect, useState } from "react"; import { useT } from "../i18n"; import CodexAccountPool from "../components/CodexAccountPool"; import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state"; +import { ensureOpenAiProvider } from "../provider-payload"; + +export function OpenAiAccountModeBanner({ + state, + busy, + onEnable, +}: { + state: CodexAccountModeState | null; + busy: boolean; + onEnable: () => void; +}) { + const t = useT(); + return ( +
+
+ {t("codexAuth.accountModeTitle")} + {state === "pool" && {t("codexAuth.accountModePool")}} + {state === "direct" && {t("codexAuth.accountModeDirect")}} +
+ {state === "pool" && ( +

{t("codexAuth.accountModePoolDesc")}

+ )} + {state === "direct" && ( +

+ {t("codexAuth.accountModeDirectDesc")} {t("codexAuth.openProviders")} +

+ )} + {(state === "absent" || state === "disabled") && ( +
+

{t("codexAuth.openaiUnavailableDesc")}

+ +
+ )} +
+ ); +} /** * Codex Auth page — a thin wrapper around CodexAccountPool (WP060 extraction). @@ -11,6 +49,8 @@ import { codexAccountModeState, type CodexAccountModeState } from "../codex-mult export default function CodexAuth({ apiBase }: { apiBase: string }) { const t = useT(); const [accountModeState, setAccountModeState] = useState(null); + const [enableBusy, setEnableBusy] = useState(false); + const [enableError, setEnableError] = useState(""); const loadMode = useCallback(async () => { try { @@ -25,33 +65,28 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { return () => { window.clearTimeout(timeout); window.clearInterval(iv); }; }, [loadMode]); - const banner = ( -
-
- {t("codexAuth.accountModeTitle")} - {accountModeState === "pool" && {t("codexAuth.accountModePool")}} - {accountModeState === "direct" && {t("codexAuth.accountModeDirect")}} -
- {accountModeState === "pool" && ( -

{t("codexAuth.accountModePoolDesc")}

- )} - {accountModeState === "direct" && ( -

- {t("codexAuth.accountModeDirectDesc")} {t("codexAuth.openProviders")} -

- )} - {accountModeState === "absent" && ( -

- {t("codexAuth.openaiMissing")} {t("codexAuth.openProviders")} -

- )} - {accountModeState === "disabled" && ( -

- {t("codexAuth.openaiDisabled")} {t("codexAuth.openProviders")} -

- )} -
- ); + const enableOpenAi = async () => { + setEnableBusy(true); + setEnableError(""); + try { + if (accountModeState !== "absent" && accountModeState !== "disabled") return; + await ensureOpenAiProvider(apiBase, accountModeState); + await loadMode(); + } catch (error) { + setEnableError(error instanceof Error ? error.message : t("prov.saveFailed")); + } finally { + setEnableBusy(false); + } + }; + + const banner = <> + { void enableOpenAi(); }} + /> + {enableError &&
{enableError}
} + ; return ; } diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 1281c57fc0..22fcdb50fd 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -6,6 +6,7 @@ import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/pr import ProviderDetails from "../components/provider-workspace/ProviderDetails"; import { RemoveConfirmDialog, UnsavedLeaveDialog } from "../components/provider-workspace/ProviderDialogs"; import type { WorkspaceProvider } from "../provider-workspace/catalog"; +import { codexAccountProviderNames, ensureOpenAiProvider, openAiAccountProviderState } from "../provider-payload"; import type { ProviderUpdatePatch } from "../components/provider-workspace/types"; import { oauthTosRisk } from "../oauth-tos-risk"; import { Notice } from "../ui"; @@ -407,10 +408,8 @@ export default function Providers({ apiBase }: { apiBase: string }) { } const addModalAccountRows = [ - ...Object.entries(config.providers) - .filter(([, prov]) => prov.authMode === "forward") - .sort(([a], [b]) => a.localeCompare(b)) - .map(([name]) => ({ + ...codexAccountProviderNames(config.providers) + .map(name => ({ id: name, label: formatProviderDisplayName(name), kind: "codex" as const, @@ -431,7 +430,26 @@ export default function Providers({ apiBase }: { apiBase: string }) { } } - const onAccountLogin = (provider: string) => { + const onAccountLogin = async (provider: string) => { + if (provider === "openai") { + const configured = config.providers.openai; + const state = openAiAccountProviderState(configured); + if (state === "invalid") { + notify(t("codexAuth.openaiMissing"), false); + return; + } + if (state === "absent" || state === "disabled") { + try { + await ensureOpenAiProvider(apiBase, state); + await fetchConfig(); + } catch (error) { + notify(error instanceof Error ? error.message : t("prov.saveFailed"), false); + return; + } + } + setCodexLoginOpen(true); + return; + } if (isForwardProvider(provider)) { setCodexLoginOpen(true); return; @@ -451,6 +469,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onAdded={() => { setCodexLoginOpen(false); notify(t("prov.loginOk", { provider: formatProviderDisplayName("openai"), cmd: "ocx sync" }), true); + void fetchConfig(); void fetchOauth(); void fetchProviderQuotas(true); bumpModelsRefresh(); diff --git a/gui/src/provider-payload.ts b/gui/src/provider-payload.ts index 5b975a3bc5..364041ad4b 100644 --- a/gui/src/provider-payload.ts +++ b/gui/src/provider-payload.ts @@ -14,6 +14,25 @@ export interface ProviderPostPreset { provider?: ProviderPayload; } +export function codexAccountProviderNames( + providers: Record, +): string[] { + const configuredForward = Object.entries(providers) + .filter(([, provider]) => provider.authMode === "forward") + .map(([name]) => name) + .filter(name => name !== "openai") + .sort((a, b) => a.localeCompare(b)); + return ["openai", ...configuredForward]; +} + +export function openAiAccountProviderState( + provider: { adapter?: string; authMode?: string; disabled?: boolean } | undefined, +): "absent" | "disabled" | "ready" | "invalid" { + if (!provider) return "absent"; + if (provider.adapter !== "openai-responses" || provider.authMode !== "forward") return "invalid"; + return provider.disabled === true ? "disabled" : "ready"; +} + export type CodexPresetDescriptionKey = "prov.openaiPoolDesc" | "prov.openaiDirectDesc"; export function isReservedCodexForwardPreset(preset: ProviderPostPreset): boolean { @@ -62,11 +81,49 @@ export function buildProviderPostBody( form: ProviderPayloadForm, ): { name: string; provider: ProviderPayload } { if (isReservedCodexForwardPreset(preset)) { - if (!preset.provider) throw new Error(`Missing canonical provider seed for ${preset.id}`); - return { - name: preset.id, - provider: JSON.parse(JSON.stringify(preset.provider)) as ProviderPayload, - }; + return buildReservedProviderPostBody(preset); } return { name: form.name.trim(), provider: buildProviderPayload(form) }; } + +function buildReservedProviderPostBody( + preset: ProviderPostPreset, +): { name: string; provider: ProviderPayload } { + if (!preset.provider) throw new Error(`Missing canonical provider seed for ${preset.id}`); + return { + name: preset.id, + provider: JSON.parse(JSON.stringify(preset.provider)) as ProviderPayload, + }; +} + +export async function ensureOpenAiProvider( + apiBase: string, + state: "absent" | "disabled", + fetchImpl: typeof fetch = fetch, +): Promise { + if (state === "disabled") { + const response = await fetchImpl(`${apiBase}/api/providers?name=openai`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + if (response.ok) return; + const body = await response.json().catch(() => ({})) as { error?: unknown }; + throw new Error(typeof body.error === "string" ? body.error : "Failed to enable the OpenAI provider"); + } + + const presetsResponse = await fetchImpl(`${apiBase}/api/provider-presets`); + if (!presetsResponse.ok) throw new Error("Failed to load the OpenAI provider preset"); + const data = await presetsResponse.json() as { providers?: ProviderPostPreset[] }; + const preset = data.providers?.find(provider => provider.id === "openai"); + if (!preset) throw new Error("OpenAI provider preset is unavailable"); + + const response = await fetchImpl(`${apiBase}/api/providers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildReservedProviderPostBody(preset)), + }); + if (response.ok) return; + const body = await response.json().catch(() => ({})) as { error?: unknown }; + throw new Error(typeof body.error === "string" ? body.error : "Failed to enable the OpenAI provider"); +} diff --git a/gui/tests/codex-auth-provider-enable.test.tsx b/gui/tests/codex-auth-provider-enable.test.tsx new file mode 100644 index 0000000000..6fb822572d --- /dev/null +++ b/gui/tests/codex-auth-provider-enable.test.tsx @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { LanguageProvider } from "../src/i18n/provider"; +import { OpenAiAccountModeBanner } from "../src/pages/CodexAuth"; + +let previousLanguageDescriptor: PropertyDescriptor | undefined; + +beforeEach(() => { + previousLanguageDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, "language"); + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: "en-US", + }); +}); + +afterEach(() => { + if (previousLanguageDescriptor) { + Object.defineProperty(globalThis.navigator, "language", previousLanguageDescriptor); + } else { + Reflect.deleteProperty(globalThis.navigator, "language"); + } +}); + +test("missing OpenAI provider offers an in-place enable action", () => { + const html = renderToStaticMarkup( + + undefined} /> + , + ); + + expect(html).toContain("Your OpenAI accounts are still available"); + expect(html).toContain("Enable OpenAI"); + expect(html).not.toContain('href="#providers"'); +}); + +test("disabled and busy OpenAI provider states keep the recovery action clear", () => { + const disabledHtml = renderToStaticMarkup( + + undefined} /> + , + ); + const busyHtml = renderToStaticMarkup( + + undefined} /> + , + ); + + expect(disabledHtml).toContain("Your OpenAI accounts are still available"); + expect(disabledHtml).toContain("Enable OpenAI"); + expect(busyHtml).toContain("Enabling..."); + expect(busyHtml).toContain("disabled=\"\""); +}); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 1f848c7837..59a00529a3 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -101,7 +101,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } }); + test("canonical OpenAI bypasses fake-IP DNS rejection without weakening custom providers", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + defaultProvider: "test-openai", + providers: { + "test-openai": { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + apiKey: "sk-secret-value", + }, + }, + }; + saveConfig(liveConfig); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockResolvedValue("baseUrl hostname resolves to a benchmark network (198.18.0.30)"); + + try { + const post = (body: unknown) => { + const request = new Request("http://127.0.0.1/api/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return handleManagementAPI(request, new URL(request.url), liveConfig, { + refreshCodexCatalog: async () => undefined, + }); + }; + const canonical = await post({ name: "openai", provider: canonicalDirect }); + expect(canonical?.status).toBe(200); + expect(resolvedError).not.toHaveBeenCalled(); + + const custom = await post({ + name: "custom", + provider: { adapter: "openai-chat", baseUrl: "https://custom.example.test/v1" }, + }); + expect(custom?.status).toBe(400); + expect(resolvedError).toHaveBeenCalledTimes(1); + } finally { + resolvedError.mockRestore(); + } + }); + test("provider mode PATCH is strict, persists live state, clears caches and affinity, and primes Pool only", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/provider-payload.test.ts b/tests/provider-payload.test.ts index 5e2e01cc14..400630c4ef 100644 --- a/tests/provider-payload.test.ts +++ b/tests/provider-payload.test.ts @@ -2,8 +2,11 @@ import { describe, expect, test } from "bun:test"; import { buildProviderPayload, buildProviderPostBody, + codexAccountProviderNames, codexPresetDescriptionKey, + ensureOpenAiProvider, isReservedCodexForwardPreset, + openAiAccountProviderState, } from "../gui/src/provider-payload"; import { en } from "../gui/src/i18n/en"; import { ko } from "../gui/src/i18n/ko"; @@ -13,6 +16,86 @@ import { deriveProviderPresets, providerConfigSeed } from "../src/providers/deri import { PROVIDER_REGISTRY } from "../src/providers/registry"; describe("provider dashboard payload", () => { + test("keeps OpenAI in the account entry even when its provider is absent", () => { + expect(codexAccountProviderNames({ + custom: { authMode: "forward" }, + anthropic: { authMode: "oauth" }, + })).toEqual(["openai", "custom"]); + expect(codexAccountProviderNames({ + openai: { authMode: "forward" }, + })).toEqual(["openai"]); + }); + + test("rejects noncanonical OpenAI providers before enabling account login", () => { + expect(openAiAccountProviderState(undefined)).toBe("absent"); + expect(openAiAccountProviderState({ + adapter: "openai-responses", + authMode: "forward", + disabled: true, + })).toBe("disabled"); + expect(openAiAccountProviderState({ + adapter: "openai-responses", + authMode: "forward", + })).toBe("ready"); + expect(openAiAccountProviderState({ + adapter: "openai-responses", + authMode: "key", + disabled: true, + })).toBe("invalid"); + }); + + test("enables OpenAI through the existing provider preset and provider POST", async () => { + const requests: Array<{ path: string; method: string; body?: unknown }> = []; + const preset = deriveProviderPresets().find(row => row.id === "openai")!; + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + requests.push({ + path: url.pathname, + method: init?.method ?? "GET", + ...(typeof init?.body === "string" ? { body: JSON.parse(init.body) } : {}), + }); + if (url.pathname === "/api/provider-presets") { + return Response.json({ providers: [preset] }); + } + if (url.pathname === "/api/providers") { + return Response.json({ success: true, name: "openai" }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + await ensureOpenAiProvider("http://localhost:10100", "absent", fetchImpl); + + expect(requests).toEqual([ + { path: "/api/provider-presets", method: "GET" }, + { + path: "/api/providers", + method: "POST", + body: { name: "openai", provider: preset.provider }, + }, + ]); + }); + + test("re-enables OpenAI without replacing its existing provider config", async () => { + const requests: Array<{ path: string; method: string; body?: unknown }> = []; + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + requests.push({ + path: `${url.pathname}${url.search}`, + method: init?.method ?? "GET", + ...(typeof init?.body === "string" ? { body: JSON.parse(init.body) } : {}), + }); + return Response.json({ success: true }); + }) as typeof fetch; + + await ensureOpenAiProvider("http://localhost:10100", "disabled", fetchImpl); + + expect(requests).toEqual([{ + path: "/api/providers?name=openai", + method: "PATCH", + body: { disabled: false }, + }]); + }); + test("persists explicit API-key mode for built-in OAuth providers", () => { expect(buildProviderPayload({ name: "xai", From 30ddfd8b8f6ba12cff4225332719376aefe78300 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:20:22 +0200 Subject: [PATCH 2/7] fix(gui): gate OpenAI recovery on canonical provider shape Cherry-picked #445 from @apple-ouyang (Ouyang Xingyuan) onto current dev, then require the same adapter/baseUrl/authMode checks for disabled rows in Codex Auth and reject noncanonical openai re-enable via PATCH. Co-authored-by: Ouyang Xingyuan --- gui/src/pages/CodexAuth.tsx | 50 ++++++++-- gui/src/provider-payload.ts | 26 ++++- gui/tests/codex-auth-provider-enable.test.tsx | 13 +++ src/server/management/provider-routes.ts | 20 +++- tests/management-provider-validation.test.ts | 99 +++++++++++++++++++ tests/provider-payload.test.ts | 15 +++ 6 files changed, 212 insertions(+), 11 deletions(-) diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index f158ffbad0..478b135187 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -2,14 +2,16 @@ import { useCallback, useEffect, useState } from "react"; import { useT } from "../i18n"; import CodexAccountPool from "../components/CodexAccountPool"; import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state"; -import { ensureOpenAiProvider } from "../provider-payload"; +import { ensureOpenAiProvider, openAiAccountProviderState } from "../provider-payload"; + +export type OpenAiAccountBannerState = CodexAccountModeState | "invalid" | null; export function OpenAiAccountModeBanner({ state, busy, onEnable, }: { - state: CodexAccountModeState | null; + state: OpenAiAccountBannerState; busy: boolean; onEnable: () => void; }) { @@ -37,10 +39,35 @@ export function OpenAiAccountModeBanner({ )} + {state === "invalid" && ( +

+ {t("codexAuth.openaiMissing")} {t("codexAuth.openProviders")} +

+ )} ); } +function openaiProviderFromConfig(config: unknown): { + adapter?: string; + authMode?: string; + baseUrl?: string; + disabled?: boolean; +} | undefined { + if (!config || typeof config !== "object") return undefined; + const providers = (config as { providers?: unknown }).providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return undefined; + if (!Object.hasOwn(providers, "openai")) return undefined; + const provider = (providers as Record).openai; + if (!provider || typeof provider !== "object" || Array.isArray(provider)) return undefined; + return provider as { + adapter?: string; + authMode?: string; + baseUrl?: string; + disabled?: boolean; + }; +} + /** * Codex Auth page — a thin wrapper around CodexAccountPool (WP060 extraction). * The page owns the /api/config fetch feeding the account-mode banner and @@ -48,6 +75,7 @@ export function OpenAiAccountModeBanner({ */ export default function CodexAuth({ apiBase }: { apiBase: string }) { const t = useT(); + const [bannerState, setBannerState] = useState(null); const [accountModeState, setAccountModeState] = useState(null); const [enableBusy, setEnableBusy] = useState(false); const [enableError, setEnableError] = useState(""); @@ -55,7 +83,16 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { const loadMode = useCallback(async () => { try { const config = await fetch(`${apiBase}/api/config`).then(r => r.json()); - setAccountModeState(codexAccountModeState(config)); + const providerState = openAiAccountProviderState(openaiProviderFromConfig(config)); + if (providerState === "absent" || providerState === "disabled" || providerState === "invalid") { + setBannerState(providerState); + // Non-canonical / missing rows are not a live Codex account mode. + setAccountModeState(providerState === "disabled" ? "disabled" : "absent"); + return; + } + const mode = codexAccountModeState(config); + setBannerState(mode); + setAccountModeState(mode); } catch { /* banner degrades to no badge */ } }, [apiBase]); @@ -69,8 +106,9 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { setEnableBusy(true); setEnableError(""); try { - if (accountModeState !== "absent" && accountModeState !== "disabled") return; - await ensureOpenAiProvider(apiBase, accountModeState); + // Recovery is gated on the same canonical checks as Providers → Accounts. + if (bannerState !== "absent" && bannerState !== "disabled") return; + await ensureOpenAiProvider(apiBase, bannerState); await loadMode(); } catch (error) { setEnableError(error instanceof Error ? error.message : t("prov.saveFailed")); @@ -81,7 +119,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { const banner = <> { void enableOpenAi(); }} /> diff --git a/gui/src/provider-payload.ts b/gui/src/provider-payload.ts index 364041ad4b..aacb29190a 100644 --- a/gui/src/provider-payload.ts +++ b/gui/src/provider-payload.ts @@ -14,6 +14,20 @@ export interface ProviderPostPreset { provider?: ProviderPayload; } +/** Mirrors `isCanonicalOpenAiForwardProvider` (src/providers/openai-tiers.ts). */ +const CODEX_FORWARD_BASE_URL = "https://chatgpt.com/backend-api/codex"; + +function normalizedBaseUrl(value: string): string | undefined { + try { + const url = new URL(value.trim()); + if (url.username || url.password || url.search || url.hash) return undefined; + const path = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${path}`; + } catch { + return undefined; + } +} + export function codexAccountProviderNames( providers: Record, ): string[] { @@ -26,10 +40,18 @@ export function codexAccountProviderNames( } export function openAiAccountProviderState( - provider: { adapter?: string; authMode?: string; disabled?: boolean } | undefined, + provider: { adapter?: string; authMode?: string; baseUrl?: string; disabled?: boolean } | undefined, ): "absent" | "disabled" | "ready" | "invalid" { if (!provider) return "absent"; - if (provider.adapter !== "openai-responses" || provider.authMode !== "forward") return "invalid"; + // Disabled and enabled rows share one canonical gate: adapter + authMode + baseUrl. + if ( + provider.adapter !== "openai-responses" + || provider.authMode !== "forward" + || typeof provider.baseUrl !== "string" + || normalizedBaseUrl(provider.baseUrl) !== CODEX_FORWARD_BASE_URL + ) { + return "invalid"; + } return provider.disabled === true ? "disabled" : "ready"; } diff --git a/gui/tests/codex-auth-provider-enable.test.tsx b/gui/tests/codex-auth-provider-enable.test.tsx index 6fb822572d..bf482c0b6a 100644 --- a/gui/tests/codex-auth-provider-enable.test.tsx +++ b/gui/tests/codex-auth-provider-enable.test.tsx @@ -50,3 +50,16 @@ test("disabled and busy OpenAI provider states keep the recovery action clear", expect(busyHtml).toContain("Enabling..."); expect(busyHtml).toContain("disabled=\"\""); }); + +test("noncanonical disabled OpenAI rows do not offer built-in recovery", () => { + const html = renderToStaticMarkup( + + undefined} /> + , + ); + + expect(html).toContain("The built-in OpenAI provider is not configured."); + expect(html).toContain('href="#providers"'); + expect(html).not.toContain("Enable OpenAI"); + expect(html).not.toContain("Your OpenAI accounts are still available"); +}); diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 59a00529a3..1b1d28fe27 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -235,15 +235,29 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise key !== "disabled"); + const enablingOpenAi = name === "openai" + && Object.hasOwn(rawBody, "disabled") + && rawBody.disabled === false + && config.providers[name]?.disabled === true; if (editorTouched) { const providerError = providerManagementConfigError(name, next); if (providerError) return jsonResponse({ error: providerError }, 400); const resolvedError = await providerDestinationResolvedError(name, next); if (resolvedError) return jsonResponse({ error: resolvedError }, 400); + } else if (enablingOpenAi) { + if (!isCanonicalOpenAiForwardProvider(next)) { + return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400); + } + // Fill missing mode so a disabled canonical row becomes a complete live openai entry. + if (next.codexAccountMode !== "pool" && next.codexAccountMode !== "direct") { + next.codexAccountMode = "pool"; + } + if (next.disabled === false) delete next.disabled; } const { saveConfig: save } = await import("../../config"); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 2d49a3ee56..48eaa30a0b 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -1040,6 +1040,105 @@ describe("provider management validation", () => { } }); + test("disabled-only PATCH cannot re-enable a noncanonical openai row unchanged", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-chat", + baseUrl: "https://api.openai.com/v1", + authMode: "key", + apiKey: "sk-malformed", + disabled: true, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }; + saveConfig(liveConfig); + + const server = startServer(0); + try { + const rejected = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + expect(rejected.status).toBe(400); + expect(await rejected.json()).toMatchObject({ + error: expect.stringContaining("canonical built-in provider"), + }); + + const persisted = loadConfig(); + expect(persisted.providers.openai).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://api.openai.com/v1", + authMode: "key", + disabled: true, + }); + } finally { + await server.stop(true); + } + }); + + test("disabled-only PATCH re-enables canonical openai and fills missing pool mode", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + disabled: true, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }); + + const server = startServer(0); + try { + const enabled = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + expect(enabled.status).toBe(200); + expect(await enabled.json()).toMatchObject({ success: true, name: "openai", disabled: false }); + + const persisted = loadConfig(); + expect(persisted.providers.openai).toEqual({ + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }); + expect(persisted.providers.openai.disabled).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + test("provider mode PATCH is strict, persists live state, clears caches and affinity, and primes Pool only", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/provider-payload.test.ts b/tests/provider-payload.test.ts index 400630c4ef..b36b54b356 100644 --- a/tests/provider-payload.test.ts +++ b/tests/provider-payload.test.ts @@ -31,15 +31,30 @@ describe("provider dashboard payload", () => { expect(openAiAccountProviderState({ adapter: "openai-responses", authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", disabled: true, })).toBe("disabled"); expect(openAiAccountProviderState({ adapter: "openai-responses", authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", })).toBe("ready"); expect(openAiAccountProviderState({ adapter: "openai-responses", authMode: "key", + baseUrl: "https://chatgpt.com/backend-api/codex", + disabled: true, + })).toBe("invalid"); + expect(openAiAccountProviderState({ + adapter: "openai-chat", + authMode: "forward", + baseUrl: "https://chatgpt.com/backend-api/codex", + disabled: true, + })).toBe("invalid"); + expect(openAiAccountProviderState({ + adapter: "openai-responses", + authMode: "forward", + baseUrl: "https://api.openai.com/v1", disabled: true, })).toBe("invalid"); }); From 9469d7d57e3a52ad17adf5f70bfd87eaea46fc19 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:46:57 +0200 Subject: [PATCH 3/7] fix(gui): address Codex review on OpenAI recovery gates Keep destination checks for canonical openai and only suppress benchmark/fake-IP answers; normalize baseUrl when re-enabling; localize enable failures; sync structure and provider docs with gated recovery. Co-authored-by: Ouyang Xingyuan --- .../src/content/docs/guides/providers.md | 5 ++ .../src/content/docs/ja/guides/providers.md | 2 + .../src/content/docs/ko/guides/providers.md | 2 + .../src/content/docs/ru/guides/providers.md | 5 ++ .../content/docs/zh-cn/guides/providers.md | 2 + gui/src/i18n/de.ts | 3 ++ gui/src/i18n/en.ts | 3 ++ gui/src/i18n/ja.ts | 3 ++ gui/src/i18n/ko.ts | 3 ++ gui/src/i18n/ru.ts | 3 ++ gui/src/i18n/zh.ts | 3 ++ gui/src/pages/CodexAuth.tsx | 8 +++- gui/src/pages/Providers.tsx | 8 +++- gui/src/provider-payload.ts | 34 +++++++++++--- src/lib/destination-policy.ts | 5 ++ src/providers/openai-tiers.ts | 2 +- src/server/management/provider-routes.ts | 24 +++++++--- structure/08_openai-provider-tiers.md | 6 ++- tests/management-provider-validation.test.ts | 46 +++++++++++++++++-- tests/provider-payload.test.ts | 19 ++++++++ 20 files changed, 163 insertions(+), 23 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 0da0b490dc..66b81b71ce 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -19,6 +19,11 @@ The API route publishes 1,050,000 context / 922,000 max input metadata. Its `sol-pro`, `terra-pro`, and `luna-pro` virtual ids keep their selected public identity while the wire uses the base model plus `reasoning.mode: "pro"`. +If the built-in `openai` provider is missing or disabled, the dashboard Accounts picker and Codex +Auth page can restore it: absent rows are created from the canonical preset, disabled canonical +rows are re-enabled without replacing saved mode or model settings, and noncanonical `openai` +rows are not offered that recovery path. + Shipped v1 configs migrate automatically to marker 2 and one option-aware row. The original config is retained once at `~/.opencodex/config.json.pre-openai-tiers-v2.bak`; restore it with `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`. diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 76d08821cf..1cbab2364f 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -18,6 +18,8 @@ bare `gpt-5.6-sol` は Providers ページの Pool/Direct オプションに従 max input 922,000 で `*-pro` virtual ID は公開状態を維持し、wire でベースモデルと `reasoning.mode: "pro"` に切り替わります。 +組み込み `openai` が欠落または無効な場合、ダッシュボードの Accounts ピッカーと Codex Auth から復元できます。欠落行は正規プリセットから作成され、正規の無効行は保存済みのモードやモデル設定を置き換えずに再有効化され、非正規の `openai` 行にはその復元経路は出ません。 + 出荷版 v1 config は marker 2 の単一オプション行に自動移行されます。オリジナルは `~/.opencodex/config.json.pre-openai-tiers-v2.bak` に一度保存され、次のコマンドで復元します: `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index e98d8ecd97..cc4e305460 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -18,6 +18,8 @@ bare `gpt-5.6-sol`은 Providers 페이지의 Pool/Direct 옵션을 따르고, max input 922,000이며 `*-pro` virtual id는 공개 상태에 유지되고 wire에서 base 모델과 `reasoning.mode: "pro"`로 바뀝니다. +내장 `openai` 제공자가 없거나 비활성화된 경우 대시보드 Accounts 선택기와 Codex Auth 페이지에서 복구할 수 있습니다. 없는 항목은 정규 프리셋으로 만들고, 비활성화된 정규 항목은 저장된 모드/모델 설정을 바꾸지 않고 다시 켜며, 비정규 `openai` 항목에는 그 복구 경로를 제공하지 않습니다. + shipped v1 config는 marker 2의 단일 옵션 행으로 자동 이관됩니다. 원본은 `~/.opencodex/config.json.pre-openai-tiers-v2.bak`에 한 번 보존되며 다음 명령으로 복원합니다: `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`. diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 0bafec83b6..5b9084868c 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -20,6 +20,11 @@ description: Все способы, которыми opencodex аутентиф виртуальные id `sol-pro`, `terra-pro` и `luna-pro` сохраняют выбранную публичную идентичность, тогда как в фактическом запросе используется базовая модель плюс `reasoning.mode: "pro"`. +Если встроенный провайдер `openai` отсутствует или отключён, его можно восстановить из выбора Accounts +на панели и со страницы Codex Auth: отсутствующие записи создаются из канонического пресета, отключённые +канонические записи включаются без замены сохранённого режима и настроек моделей, а неканонические +записи `openai` этот путь восстановления не получают. + Поставляемые v1-конфигурации автоматически мигрируют на маркер 2 и одну строку с поддержкой опций. Исходная конфигурация один раз сохраняется в `~/.opencodex/config.json.pre-openai-tiers-v2.bak`; восстановить её можно командой diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index c52a6dfa4d..24073ccc4b 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -17,6 +17,8 @@ bare `gpt-5.6-sol` 遵循 Providers 页面中的 Pool/Direct 选项, 922,000 max input;`*-pro` virtual id 保留在公开状态中,线上改写为 base 模型加 `reasoning.mode: "pro"`。 +若内置 `openai` 提供商缺失或已禁用,可在仪表盘 Accounts 选择器或 Codex Auth 页面恢复:缺失行会从规范预设创建,已禁用的规范行会在不替换已保存模式/模型设置的情况下重新启用,非规范的 `openai` 行不会提供该恢复路径。 + shipped v1 配置自动迁移到 marker 2 的单一选项行。原配置只保留一次到 `~/.opencodex/config.json.pre-openai-tiers-v2.bak`;恢复命令: `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 11d0ba8a80..76639a7f82 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -613,6 +613,9 @@ export const de: Record = { "codexAuth.openaiUnavailableDesc": "Deine OpenAI-Konten sind weiterhin verfügbar. Aktiviere den Anbieter, um Codex-Anfragen weiterzuleiten.", "codexAuth.enableOpenai": "OpenAI aktivieren", "codexAuth.enablingOpenai": "Wird aktiviert...", + "codexAuth.enableOpenaiFailed": "OpenAI-Anbieter konnte nicht aktiviert werden.", + "codexAuth.openaiPresetLoadFailed": "OpenAI-Anbieter-Preset konnte nicht geladen werden.", + "codexAuth.openaiPresetUnavailable": "OpenAI-Anbieter-Preset ist nicht verfügbar.", "codexAuth.openProviders": "Anbieter öffnen", "codexAuth.add": "Hinzufügen", "codexAuth.refreshQuota": "Kontingente aktualisieren", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9cb94f08b6..b6712e9fb5 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -911,6 +911,9 @@ export const en = { "codexAuth.openaiUnavailableDesc": "Your OpenAI accounts are still available. Enable the provider to route Codex requests.", "codexAuth.enableOpenai": "Enable OpenAI", "codexAuth.enablingOpenai": "Enabling...", + "codexAuth.enableOpenaiFailed": "Failed to enable the OpenAI provider.", + "codexAuth.openaiPresetLoadFailed": "Failed to load the OpenAI provider preset.", + "codexAuth.openaiPresetUnavailable": "OpenAI provider preset is unavailable.", "codexAuth.openProviders": "Open Providers", "codexAuth.add": "Add", "codexAuth.refreshQuota": "Refresh quotas", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 80c076fe67..afb6878065 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -865,6 +865,9 @@ export const ja: Record = { "codexAuth.openaiUnavailableDesc": "OpenAI アカウントは引き続き利用できます。Codex リクエストをルーティングするにはプロバイダーを有効にしてください。", "codexAuth.enableOpenai": "OpenAI を有効にする", "codexAuth.enablingOpenai": "有効化中...", + "codexAuth.enableOpenaiFailed": "OpenAI プロバイダーを有効にできませんでした。", + "codexAuth.openaiPresetLoadFailed": "OpenAI プロバイダーのプリセットを読み込めませんでした。", + "codexAuth.openaiPresetUnavailable": "OpenAI プロバイダーのプリセットを利用できません。", "codexAuth.openProviders": "プロバイダーを開く", "codexAuth.add": "追加", "codexAuth.refreshQuota": "クォータを更新", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 90e05b8aec..6fea283bd1 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -630,6 +630,9 @@ export const ko: Record = { "codexAuth.openaiUnavailableDesc": "OpenAI 계정은 그대로 사용할 수 있습니다. Codex 요청을 라우팅하려면 프로바이더를 활성화하세요.", "codexAuth.enableOpenai": "OpenAI 활성화", "codexAuth.enablingOpenai": "활성화 중...", + "codexAuth.enableOpenaiFailed": "OpenAI 공급자를 활성화하지 못했습니다.", + "codexAuth.openaiPresetLoadFailed": "OpenAI 공급자 프리셋을 불러오지 못했습니다.", + "codexAuth.openaiPresetUnavailable": "OpenAI 공급자 프리셋을 사용할 수 없습니다.", "codexAuth.openProviders": "프로바이더 열기", "codexAuth.add": "추가", "codexAuth.refreshQuota": "할당량 새로고침", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 98809fefb9..6b7da51540 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -910,6 +910,9 @@ export const ru: Record = { "codexAuth.openaiUnavailableDesc": "Ваши аккаунты OpenAI по-прежнему доступны. Включите провайдера для маршрутизации запросов Codex.", "codexAuth.enableOpenai": "Включить OpenAI", "codexAuth.enablingOpenai": "Включение...", + "codexAuth.enableOpenaiFailed": "Не удалось включить провайдер OpenAI.", + "codexAuth.openaiPresetLoadFailed": "Не удалось загрузить пресет провайдера OpenAI.", + "codexAuth.openaiPresetUnavailable": "Пресет провайдера OpenAI недоступен.", "codexAuth.openProviders": "Открыть провайдеров", "codexAuth.add": "Добавить", "codexAuth.refreshQuota": "Обновить квоты", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 30896111cf..daa893094c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -630,6 +630,9 @@ export const zh: Record = { "codexAuth.openaiUnavailableDesc": "你的 OpenAI 账号仍然可用。启用提供方后即可路由 Codex 请求。", "codexAuth.enableOpenai": "启用 OpenAI", "codexAuth.enablingOpenai": "正在启用...", + "codexAuth.enableOpenaiFailed": "无法启用 OpenAI 提供商。", + "codexAuth.openaiPresetLoadFailed": "无法加载 OpenAI 提供商预设。", + "codexAuth.openaiPresetUnavailable": "OpenAI 提供商预设不可用。", "codexAuth.openProviders": "打开提供商", "codexAuth.add": "添加", "codexAuth.refreshQuota": "刷新额度", diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index 478b135187..e8ddc05016 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useT } from "../i18n"; import CodexAccountPool from "../components/CodexAccountPool"; import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state"; -import { ensureOpenAiProvider, openAiAccountProviderState } from "../provider-payload"; +import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; export type OpenAiAccountBannerState = CodexAccountModeState | "invalid" | null; @@ -111,7 +111,11 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { await ensureOpenAiProvider(apiBase, bannerState); await loadMode(); } catch (error) { - setEnableError(error instanceof Error ? error.message : t("prov.saveFailed")); + if (error instanceof OpenAiEnableError) { + setEnableError(error.serverMessage ?? t(error.i18nKey)); + } else { + setEnableError(error instanceof Error ? error.message : t("prov.saveFailed")); + } } finally { setEnableBusy(false); } diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 22fcdb50fd..dd3842ffaa 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -6,7 +6,7 @@ import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/pr import ProviderDetails from "../components/provider-workspace/ProviderDetails"; import { RemoveConfirmDialog, UnsavedLeaveDialog } from "../components/provider-workspace/ProviderDialogs"; import type { WorkspaceProvider } from "../provider-workspace/catalog"; -import { codexAccountProviderNames, ensureOpenAiProvider, openAiAccountProviderState } from "../provider-payload"; +import { codexAccountProviderNames, ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; import type { ProviderUpdatePatch } from "../components/provider-workspace/types"; import { oauthTosRisk } from "../oauth-tos-risk"; import { Notice } from "../ui"; @@ -443,7 +443,11 @@ export default function Providers({ apiBase }: { apiBase: string }) { await ensureOpenAiProvider(apiBase, state); await fetchConfig(); } catch (error) { - notify(error instanceof Error ? error.message : t("prov.saveFailed"), false); + if (error instanceof OpenAiEnableError) { + notify(error.serverMessage ?? t(error.i18nKey), false); + } else { + notify(error instanceof Error ? error.message : t("prov.saveFailed"), false); + } return; } } diff --git a/gui/src/provider-payload.ts b/gui/src/provider-payload.ts index aacb29190a..65d76f5f6e 100644 --- a/gui/src/provider-payload.ts +++ b/gui/src/provider-payload.ts @@ -118,6 +118,30 @@ function buildReservedProviderPostBody( }; } +/** Stable i18n keys for OpenAI enable failures (no hardcoded UI English). */ +export type OpenAiEnableErrorKey = + | "codexAuth.enableOpenaiFailed" + | "codexAuth.openaiPresetLoadFailed" + | "codexAuth.openaiPresetUnavailable"; + +export class OpenAiEnableError extends Error { + readonly i18nKey: OpenAiEnableErrorKey; + /** Optional management-API error text; callers may surface it when present. */ + readonly serverMessage?: string; + + constructor(i18nKey: OpenAiEnableErrorKey, serverMessage?: string) { + super(serverMessage ?? i18nKey); + this.name = "OpenAiEnableError"; + this.i18nKey = i18nKey; + this.serverMessage = serverMessage; + } +} + +async function readProviderErrorMessage(response: Response): Promise { + const body = await response.json().catch(() => ({})) as { error?: unknown }; + return typeof body.error === "string" ? body.error : undefined; +} + export async function ensureOpenAiProvider( apiBase: string, state: "absent" | "disabled", @@ -130,15 +154,14 @@ export async function ensureOpenAiProvider( body: JSON.stringify({ disabled: false }), }); if (response.ok) return; - const body = await response.json().catch(() => ({})) as { error?: unknown }; - throw new Error(typeof body.error === "string" ? body.error : "Failed to enable the OpenAI provider"); + throw new OpenAiEnableError("codexAuth.enableOpenaiFailed", await readProviderErrorMessage(response)); } const presetsResponse = await fetchImpl(`${apiBase}/api/provider-presets`); - if (!presetsResponse.ok) throw new Error("Failed to load the OpenAI provider preset"); + if (!presetsResponse.ok) throw new OpenAiEnableError("codexAuth.openaiPresetLoadFailed"); const data = await presetsResponse.json() as { providers?: ProviderPostPreset[] }; const preset = data.providers?.find(provider => provider.id === "openai"); - if (!preset) throw new Error("OpenAI provider preset is unavailable"); + if (!preset) throw new OpenAiEnableError("codexAuth.openaiPresetUnavailable"); const response = await fetchImpl(`${apiBase}/api/providers`, { method: "POST", @@ -146,6 +169,5 @@ export async function ensureOpenAiProvider( body: JSON.stringify(buildReservedProviderPostBody(preset)), }); if (response.ok) return; - const body = await response.json().catch(() => ({})) as { error?: unknown }; - throw new Error(typeof body.error === "string" ? body.error : "Failed to enable the OpenAI provider"); + throw new OpenAiEnableError("codexAuth.enableOpenaiFailed", await readProviderErrorMessage(response)); } diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index 5bcb2347a2..fd20cf4219 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -134,6 +134,11 @@ export function assertProviderDestinationAllowed(name: string, provider: Pick, diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 92b94cd294..4ad1ba7078 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -6,7 +6,7 @@ export const LEGACY_OPENAI_MULTI_PROVIDER_ID = "openai-multi"; export const OPENAI_API_PROVIDER_ID = "openai-apikey"; export const LEGACY_CHATGPT_PROVIDER_ID = "chatgpt"; -const CODEX_FORWARD_BASE_URL = "https://chatgpt.com/backend-api/codex"; +export const CODEX_FORWARD_BASE_URL = "https://chatgpt.com/backend-api/codex"; const LEGACY_OPENAI_MULTI_PREFIX = `${LEGACY_OPENAI_MULTI_PROVIDER_ID}/`; function canonicalCodexForwardProvider(mode: CodexAccountMode): OcxProviderConfig { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 1b1d28fe27..44bcb42a70 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -22,13 +22,13 @@ import { upsertOAuthProvider, } from "../../oauth"; import { removeCredential } from "../../oauth/store"; -import { providerDestinationResolvedError } from "../../lib/destination-policy"; +import { isBenchmarkDestinationError, providerDestinationResolvedError } from "../../lib/destination-policy"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { getProviderDiscoveryStatus } from "../../codex/model-cache"; @@ -101,10 +101,19 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } }); - test("canonical OpenAI bypasses fake-IP DNS rejection without weakening custom providers", async () => { + test("canonical OpenAI suppresses only fake-IP/benchmark DNS rejection", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; @@ -1012,7 +1012,7 @@ describe("provider management validation", () => { }; saveConfig(liveConfig); const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") - .mockResolvedValue("baseUrl hostname resolves to a benchmark network (198.18.0.30)"); + .mockResolvedValue("baseUrl hostname chatgpt.com resolves to a benchmark address (198.18.0.30); set allowPrivateNetwork:true only for intentionally local/self-hosted providers"); try { const post = (body: unknown) => { @@ -1027,13 +1027,51 @@ describe("provider management validation", () => { }; const canonical = await post({ name: "openai", provider: canonicalDirect }); expect(canonical?.status).toBe(200); - expect(resolvedError).not.toHaveBeenCalled(); + expect(resolvedError).toHaveBeenCalledTimes(1); const custom = await post({ name: "custom", provider: { adapter: "openai-chat", baseUrl: "https://custom.example.test/v1" }, }); expect(custom?.status).toBe(400); + expect(resolvedError).toHaveBeenCalledTimes(2); + } finally { + resolvedError.mockRestore(); + } + }); + + test("canonical OpenAI still rejects non-benchmark private destination answers", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + defaultProvider: "test-openai", + providers: { + "test-openai": { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + apiKey: "sk-secret-value", + }, + }, + }; + saveConfig(liveConfig); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockResolvedValue("baseUrl hostname chatgpt.com resolves to a loopback address (127.0.0.1); set allowPrivateNetwork:true only for intentionally local/self-hosted providers"); + + try { + const request = new Request("http://127.0.0.1/api/providers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "openai", provider: canonicalDirect }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { + refreshCodexCatalog: async () => undefined, + }); + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ + error: expect.stringContaining("loopback address"), + }); expect(resolvedError).toHaveBeenCalledTimes(1); } finally { resolvedError.mockRestore(); @@ -1103,7 +1141,7 @@ describe("provider management validation", () => { providers: { openai: { adapter: "openai-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", + baseUrl: "https://CHATGPT.com:443/backend-api/codex", authMode: "forward", disabled: true, }, diff --git a/tests/provider-payload.test.ts b/tests/provider-payload.test.ts index b36b54b356..9dd586f145 100644 --- a/tests/provider-payload.test.ts +++ b/tests/provider-payload.test.ts @@ -7,6 +7,7 @@ import { ensureOpenAiProvider, isReservedCodexForwardPreset, openAiAccountProviderState, + OpenAiEnableError, } from "../gui/src/provider-payload"; import { en } from "../gui/src/i18n/en"; import { ko } from "../gui/src/i18n/ko"; @@ -111,6 +112,24 @@ describe("provider dashboard payload", () => { }]); }); + test("surfaces OpenAI enable failures as stable i18n keys", async () => { + const fetchImpl = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname === "/api/provider-presets") { + return new Response("nope", { status: 500 }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + try { + await ensureOpenAiProvider("http://localhost:10100", "absent", fetchImpl); + expect.unreachable("expected OpenAiEnableError"); + } catch (error) { + expect(error).toBeInstanceOf(OpenAiEnableError); + expect((error as OpenAiEnableError).i18nKey).toBe("codexAuth.openaiPresetLoadFailed"); + } + }); + test("persists explicit API-key mode for built-in OAuth providers", () => { expect(buildProviderPayload({ name: "xai", From b77c43ee9636697cfa4a5a35ac66a3a19136117b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:50:07 +0200 Subject: [PATCH 4/7] test(gui): cover Codex Auth OpenAI recovery click journey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prove absent→POST, disabled→PATCH, localized error display, and post-success Pool refresh through the rendered page, not banner markup alone. --- .../codex-auth-recovery-interaction.test.tsx | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 gui/tests/codex-auth-recovery-interaction.test.tsx diff --git a/gui/tests/codex-auth-recovery-interaction.test.tsx b/gui/tests/codex-auth-recovery-interaction.test.tsx new file mode 100644 index 0000000000..e41f38a85e --- /dev/null +++ b/gui/tests/codex-auth-recovery-interaction.test.tsx @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import CodexAuth from "../src/pages/CodexAuth"; + +/** + * Interaction-level recovery journey for Codex Auth. + * Banner markup tests alone cannot prove absent→POST, disabled→PATCH, + * error display, or post-success config refresh. + */ + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; +let requests: Array<{ method: string; path: string; body?: unknown }> = []; +let configProviders: Record = {}; + +const CANONICAL_OPENAI = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", +}; + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + originalFetch = globalThis.fetch; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + requests = []; + configProviders = {}; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + const entry: { method: string; path: string; body?: unknown } = { + method, + path: `${url.pathname}${url.search}`, + }; + if (typeof init?.body === "string") entry.body = JSON.parse(init.body); + requests.push(entry); + + if (url.pathname === "/api/config") { + return Response.json({ providers: configProviders }); + } + if (url.pathname === "/api/provider-presets") { + return Response.json({ + providers: [{ id: "openai", codexAccountMode: "pool", provider: { ...CANONICAL_OPENAI } }], + }); + } + if (url.pathname === "/api/providers" && method === "POST") { + configProviders = { openai: { ...CANONICAL_OPENAI } }; + return Response.json({ success: true, name: "openai" }); + } + if (url.pathname === "/api/providers" && method === "PATCH") { + const existing = configProviders.openai; + if (existing && typeof existing === "object") { + const next = { ...(existing as Record) }; + delete next.disabled; + configProviders = { openai: next }; + } + return Response.json({ success: true, name: "openai", disabled: false }); + } + if (url.pathname.startsWith("/api/codex-auth/accounts")) { + return Response.json({ + accounts: [{ id: "main", email: "main@example.test", isMain: true, hasCredential: true, quota: null }], + }); + } + if (url.pathname.startsWith("/api/codex-auth/active")) { + return Response.json({ activeCodexAccountId: null, autoSwitchThreshold: 80 }); + } + return Response.json({}); + }, + }); + + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +async function mountPage() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + // CodexAuth loads mode via setTimeout(0); pool controller settles shortly after. + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); +} + +function enableButton(): HTMLButtonElement { + const button = Array.from(host.querySelectorAll("button")).find((el) => + (el.textContent ?? "").includes("Enable OpenAI"), + ); + expect(button).toBeTruthy(); + return button as HTMLButtonElement; +} + +test("absent OpenAI: Enable posts the canonical preset and refreshes to Pool mode", async () => { + configProviders = {}; + await mountPage(); + + expect(host.textContent).toContain("Your OpenAI accounts are still available"); + expect(host.textContent).toContain("Enable OpenAI"); + + await act(async () => { + enableButton().dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 40)); + }); + + expect(requests.some((r) => r.method === "GET" && r.path === "/api/provider-presets")).toBe(true); + expect(requests.some((r) => + r.method === "POST" + && r.path === "/api/providers" + && (r.body as { name?: string })?.name === "openai", + )).toBe(true); + expect(host.textContent).toContain("Pool mode"); + expect(host.textContent).not.toContain("Enable OpenAI"); + expect(host.querySelector('[role="alert"]')).toBeNull(); +}); + +test("disabled canonical OpenAI: Enable PATCHes disabled:false and refreshes", async () => { + configProviders = { + openai: { ...CANONICAL_OPENAI, disabled: true }, + }; + await mountPage(); + + expect(host.textContent).toContain("Enable OpenAI"); + + await act(async () => { + enableButton().dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 40)); + }); + + expect(requests.some((r) => + r.method === "PATCH" + && r.path === "/api/providers?name=openai" + && JSON.stringify(r.body) === JSON.stringify({ disabled: false }), + )).toBe(true); + expect(host.textContent).toContain("Pool mode"); + expect(host.textContent).not.toContain("Enable OpenAI"); +}); + +test("absent OpenAI: preset load failure surfaces a localized error alert", async () => { + configProviders = {}; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + requests.push({ method, path: `${url.pathname}${url.search}` }); + if (url.pathname === "/api/config") return Response.json({ providers: configProviders }); + if (url.pathname === "/api/provider-presets") return new Response("nope", { status: 500 }); + if (url.pathname.startsWith("/api/codex-auth/")) { + return Response.json({ accounts: [], activeCodexAccountId: null, autoSwitchThreshold: 80 }); + } + return Response.json({}); + }, + }); + + await mountPage(); + await act(async () => { + enableButton().dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 40)); + }); + + const alert = host.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + expect(alert!.textContent).toContain("Failed to load the OpenAI provider preset."); + expect(host.textContent).toContain("Enable OpenAI"); + expect(requests.some((r) => r.method === "POST" && r.path === "/api/providers")).toBe(false); +}); From fe5648d1ff31701fe2b74bdf18af9f43bbbe8252 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:57:35 +0200 Subject: [PATCH 5/7] fix(proxy): tighten canonical OpenAI recovery security gates Allow only Clash 198.18/19 answers inside destination resolution for the canonical seed, normalize baseUrl on re-enable with save-and-reload coverage, and always localize enable failures through t(...). --- gui/src/pages/CodexAuth.tsx | 2 +- gui/src/pages/Providers.tsx | 2 +- src/lib/destination-policy.ts | 18 ++-- src/server/management/provider-routes.ts | 20 ++--- tests/destination-policy-resolved.test.ts | 67 +++++++++++++++ tests/management-provider-validation.test.ts | 86 ++++++++++++++++++-- 6 files changed, 167 insertions(+), 28 deletions(-) diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index e8ddc05016..f3abed4234 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -112,7 +112,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { await loadMode(); } catch (error) { if (error instanceof OpenAiEnableError) { - setEnableError(error.serverMessage ?? t(error.i18nKey)); + setEnableError(t(error.i18nKey)); } else { setEnableError(error instanceof Error ? error.message : t("prov.saveFailed")); } diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index dd3842ffaa..fa06b83057 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -444,7 +444,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { await fetchConfig(); } catch (error) { if (error instanceof OpenAiEnableError) { - notify(error.serverMessage ?? t(error.i18nKey), false); + notify(t(error.i18nKey), false); } else { notify(error instanceof Error ? error.message : t("prov.saveFailed"), false); } diff --git a/src/lib/destination-policy.ts b/src/lib/destination-policy.ts index fd20cf4219..54cffcbd19 100644 --- a/src/lib/destination-policy.ts +++ b/src/lib/destination-policy.ts @@ -133,15 +133,15 @@ export function assertProviderDestinationAllowed(name: string, provider: Pick, + options?: { allowBenchmarkAddresses?: boolean }, ): Promise { const syncError = providerDestinationConfigError(name, provider); if (syncError) return syncError; @@ -165,6 +165,14 @@ export async function providerDestinationResolvedError( const ipKind = isIP(address); const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null; if (!assessment || assessment.kind === "public") continue; + // Clash fake-IP only: 198.18/19 benchmark detail. Mixed dangerous sets still reject. + if ( + options?.allowBenchmarkAddresses + && assessment.kind === "private" + && assessment.detail === "benchmark address" + ) { + continue; + } if (assessment.kind === "metadata") return `baseUrl hostname ${hostname} resolves to a blocked metadata endpoint (${address})`; return `baseUrl hostname ${hostname} resolves to a ${assessment.detail} (${address}); set allowPrivateNetwork:true only for intentionally local/self-hosted providers`; } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 44bcb42a70..7d910a0800 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -22,7 +22,7 @@ import { upsertOAuthProvider, } from "../../oauth"; import { removeCredential } from "../../oauth/store"; -import { isBenchmarkDestinationError, providerDestinationResolvedError } from "../../lib/destination-policy"; +import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; @@ -101,19 +101,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { + test("allows pure 198.18.0.0/15 benchmark answers when opted in", async () => { + lookupMock.mockResolvedValueOnce([ + { address: "198.18.0.30", family: 4 }, + { address: "198.19.1.2", family: 4 }, + ]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + )).toBeNull(); + }); + + test("still rejects loopback, RFC1918, and metadata even with the opt-in", async () => { + lookupMock.mockResolvedValueOnce([{ address: "127.0.0.1", family: 4 }]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + )).toContain("loopback address (127.0.0.1)"); + + lookupMock.mockResolvedValueOnce([{ address: "10.0.0.5", family: 4 }]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + )).toContain("private-network address (10.0.0.5)"); + + lookupMock.mockResolvedValueOnce([{ address: "169.254.169.254", family: 4 }]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + )).toContain("blocked metadata endpoint (169.254.169.254)"); + }); + + test("rejects mixed benchmark plus private or metadata answers", async () => { + lookupMock.mockResolvedValueOnce([ + { address: "198.18.0.30", family: 4 }, + { address: "10.0.0.5", family: 4 }, + ]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + )).toContain("private-network address (10.0.0.5)"); + + lookupMock.mockResolvedValueOnce([ + { address: "198.18.0.30", family: 4 }, + { address: "169.254.169.254", family: 4 }, + ]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + { allowBenchmarkAddresses: true }, + )).toContain("blocked metadata endpoint (169.254.169.254)"); + }); + + test("without the opt-in, benchmark answers are still rejected", async () => { + lookupMock.mockResolvedValueOnce([{ address: "198.18.0.30", family: 4 }]); + expect(await providerDestinationResolvedError( + "openai", + provider("https://chatgpt.com/backend-api/codex"), + )).toContain("benchmark address (198.18.0.30)"); + }); +}); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 272618754f..92ff9cd3e6 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -995,7 +995,7 @@ describe("provider management validation", () => { } }); - test("canonical OpenAI suppresses only fake-IP/benchmark DNS rejection", async () => { + test("canonical OpenAI POST passes allowBenchmarkAddresses into destination resolution", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; @@ -1012,7 +1012,7 @@ describe("provider management validation", () => { }; saveConfig(liveConfig); const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") - .mockResolvedValue("baseUrl hostname chatgpt.com resolves to a benchmark address (198.18.0.30); set allowPrivateNetwork:true only for intentionally local/self-hosted providers"); + .mockResolvedValue(null); try { const post = (body: unknown) => { @@ -1027,20 +1027,31 @@ describe("provider management validation", () => { }; const canonical = await post({ name: "openai", provider: canonicalDirect }); expect(canonical?.status).toBe(200); - expect(resolvedError).toHaveBeenCalledTimes(1); + expect(resolvedError).toHaveBeenCalledWith( + "openai", + expect.objectContaining({ baseUrl: canonicalDirect.baseUrl }), + { allowBenchmarkAddresses: true }, + ); + resolvedError.mockResolvedValueOnce( + "baseUrl hostname custom.example.test resolves to a benchmark address (198.18.0.30); set allowPrivateNetwork:true only for intentionally local/self-hosted providers", + ); const custom = await post({ name: "custom", provider: { adapter: "openai-chat", baseUrl: "https://custom.example.test/v1" }, }); expect(custom?.status).toBe(400); - expect(resolvedError).toHaveBeenCalledTimes(2); + expect(resolvedError).toHaveBeenCalledWith( + "custom", + expect.objectContaining({ baseUrl: "https://custom.example.test/v1" }), + { allowBenchmarkAddresses: false }, + ); } finally { resolvedError.mockRestore(); } }); - test("canonical OpenAI still rejects non-benchmark private destination answers", async () => { + test("canonical OpenAI POST still rejects non-benchmark private destination answers", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; @@ -1072,7 +1083,11 @@ describe("provider management validation", () => { expect(await response?.json()).toMatchObject({ error: expect.stringContaining("loopback address"), }); - expect(resolvedError).toHaveBeenCalledTimes(1); + expect(resolvedError).toHaveBeenCalledWith( + "openai", + expect.anything(), + { allowBenchmarkAddresses: true }, + ); } finally { resolvedError.mockRestore(); } @@ -1141,7 +1156,7 @@ describe("provider management validation", () => { providers: { openai: { adapter: "openai-responses", - baseUrl: "https://CHATGPT.com:443/backend-api/codex", + baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", disabled: true, }, @@ -1177,6 +1192,63 @@ describe("provider management validation", () => { } }); + for (const [label, baseUrl] of [ + ["uppercase host", "https://CHATGPT.com/backend-api/codex"], + ["explicit :443 port", "https://chatgpt.com:443/backend-api/codex"], + ] as const) { + test(`disabled-only PATCH normalizes ${label} before save-and-reload`, async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig({ + port: 0, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl, + authMode: "forward", + disabled: true, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }); + + const server = startServer(0); + try { + const enabled = await fetch(new URL("/api/providers?name=openai", server.url), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + expect(enabled.status).toBe(200); + + const afterSave = loadConfig(); + expect(afterSave.providers.openai).toEqual({ + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }); + + // Second load proves the persisted row survives config-schema restart checks. + const afterReload = loadConfig(); + expect(afterReload.providers.openai.baseUrl).toBe("https://chatgpt.com/backend-api/codex"); + expect(afterReload.providers.openai.codexAccountMode).toBe("pool"); + expect(afterReload.providers.openai.disabled).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + } + test("provider mode PATCH is strict, persists live state, clears caches and affinity, and primes Pool only", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); From 196a6a814ef8e0a23d4d699f41f9d8845f96b9c5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:20:11 +0200 Subject: [PATCH 6/7] fix(proxy): run DNS guard on disabled OpenAI re-enable PATCH {disabled:false} for openai now resolves destinations with the canonical base URL and allowBenchmarkAddresses, ignoring any persisted allowPrivateNetwork so Clash fake-IP alone can pass. --- src/server/management/provider-routes.ts | 8 + tests/management-provider-validation.test.ts | 170 +++++++++++++++++++ 2 files changed, 178 insertions(+) diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 7d910a0800..0cbd29b2fd 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -261,6 +261,14 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } }); + test("disabled OpenAI recovery accepts pure Clash fake-IP via destination check", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + disabled: true, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }; + saveConfig(liveConfig); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockImplementation(async (_name, provider, options) => { + expect(provider).toEqual({ baseUrl: "https://chatgpt.com/backend-api/codex" }); + expect(options).toEqual({ allowBenchmarkAddresses: true }); + return null; // pure 198.18/19 allowed by the policy opt-in + }); + + try { + const request = new Request("http://127.0.0.1/api/providers?name=openai", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { + refreshCodexCatalog: async () => undefined, + }); + expect(response?.status).toBe(200); + expect(resolvedError).toHaveBeenCalledTimes(1); + expect(liveConfig.providers.openai?.disabled).toBeUndefined(); + } finally { + resolvedError.mockRestore(); + } + }); + + test("disabled OpenAI recovery rejects loopback, RFC1918, and metadata and stays disabled", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const disabledCanonical = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + disabled: true, + } as const; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { ...disabledCanonical }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }; + saveConfig(liveConfig); + + const failures = [ + "baseUrl hostname chatgpt.com resolves to a loopback address (127.0.0.1); set allowPrivateNetwork:true only for intentionally local/self-hosted providers", + "baseUrl hostname chatgpt.com resolves to a private-network address (10.0.0.5); set allowPrivateNetwork:true only for intentionally local/self-hosted providers", + "baseUrl hostname chatgpt.com resolves to a blocked metadata endpoint (169.254.169.254)", + ]; + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError"); + + try { + for (const error of failures) { + liveConfig.providers.openai = { ...disabledCanonical }; + saveConfig(liveConfig); + resolvedError.mockResolvedValueOnce(error); + + const request = new Request("http://127.0.0.1/api/providers?name=openai", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { + refreshCodexCatalog: async () => undefined, + }); + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ error }); + expect(liveConfig.providers.openai).toEqual(disabledCanonical); + expect(loadConfig().providers.openai).toMatchObject({ disabled: true }); + } + expect(resolvedError).toHaveBeenCalledTimes(failures.length); + expect(resolvedError).toHaveBeenCalledWith( + "openai", + { baseUrl: "https://chatgpt.com/backend-api/codex" }, + { allowBenchmarkAddresses: true }, + ); + } finally { + resolvedError.mockRestore(); + } + }); + + test("disabled OpenAI recovery ignores persisted allowPrivateNetwork for DNS guard", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + allowPrivateNetwork: true, + disabled: true, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }; + saveConfig(liveConfig); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockImplementation(async (_name, provider, options) => { + expect(provider).toEqual({ baseUrl: "https://chatgpt.com/backend-api/codex" }); + expect(Object.hasOwn(provider as object, "allowPrivateNetwork")).toBe(false); + expect(options).toEqual({ allowBenchmarkAddresses: true }); + return "baseUrl hostname chatgpt.com resolves to a private-network address (10.0.0.5); set allowPrivateNetwork:true only for intentionally local/self-hosted providers"; + }); + + try { + const request = new Request("http://127.0.0.1/api/providers?name=openai", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { + refreshCodexCatalog: async () => undefined, + }); + expect(response?.status).toBe(400); + expect(liveConfig.providers.openai).toMatchObject({ + disabled: true, + allowPrivateNetwork: true, + }); + expect(loadConfig().providers.openai).toMatchObject({ + disabled: true, + allowPrivateNetwork: true, + }); + } finally { + resolvedError.mockRestore(); + } + }); + for (const [label, baseUrl] of [ ["uppercase host", "https://CHATGPT.com/backend-api/codex"], ["explicit :443 port", "https://chatgpt.com:443/backend-api/codex"], From 077a407a45c6399dcd922dfadde3e79f82a4ceeb Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:10:25 +0200 Subject: [PATCH 7/7] fix(gui): address CodeRabbit OpenAI recovery follow-ups Guard Accounts enable against double-clicks, localize the missing-seed path, unify zh provider wording, and drop stale allowPrivateNetwork after a successful disabled-row re-enable. --- .../provider-catalog/ProviderCatalog.tsx | 8 ++- gui/src/i18n/zh.ts | 6 +-- gui/src/pages/Providers.tsx | 4 ++ gui/src/provider-payload.ts | 18 ++----- src/server/management/provider-routes.ts | 3 ++ tests/management-provider-validation.test.ts | 52 +++++++++++++++++++ tests/provider-payload.test.ts | 18 +++++++ 7 files changed, 91 insertions(+), 18 deletions(-) diff --git a/gui/src/components/provider-catalog/ProviderCatalog.tsx b/gui/src/components/provider-catalog/ProviderCatalog.tsx index 47e1201b38..6f025bb0e1 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -152,8 +152,12 @@ export default function ProviderCatalog({ {t("modal.accountManage")} )} {onLogin && ( - )} diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index daa893094c..c6ae2ae714 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -630,9 +630,9 @@ export const zh: Record = { "codexAuth.openaiUnavailableDesc": "你的 OpenAI 账号仍然可用。启用提供方后即可路由 Codex 请求。", "codexAuth.enableOpenai": "启用 OpenAI", "codexAuth.enablingOpenai": "正在启用...", - "codexAuth.enableOpenaiFailed": "无法启用 OpenAI 提供商。", - "codexAuth.openaiPresetLoadFailed": "无法加载 OpenAI 提供商预设。", - "codexAuth.openaiPresetUnavailable": "OpenAI 提供商预设不可用。", + "codexAuth.enableOpenaiFailed": "无法启用 OpenAI 提供方。", + "codexAuth.openaiPresetLoadFailed": "无法加载 OpenAI 提供方预设。", + "codexAuth.openaiPresetUnavailable": "OpenAI 提供方预设不可用。", "codexAuth.openProviders": "打开提供商", "codexAuth.add": "添加", "codexAuth.refreshQuota": "刷新额度", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index fa06b83057..a0c8553fab 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -432,6 +432,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const onAccountLogin = async (provider: string) => { if (provider === "openai") { + if (busy === "openai") return; const configured = config.providers.openai; const state = openAiAccountProviderState(configured); if (state === "invalid") { @@ -439,6 +440,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { return; } if (state === "absent" || state === "disabled") { + setBusy("openai"); try { await ensureOpenAiProvider(apiBase, state); await fetchConfig(); @@ -449,6 +451,8 @@ export default function Providers({ apiBase }: { apiBase: string }) { notify(error instanceof Error ? error.message : t("prov.saveFailed"), false); } return; + } finally { + if (aliveRef.current) setBusy(current => current === "openai" ? null : current); } } setCodexLoginOpen(true); diff --git a/gui/src/provider-payload.ts b/gui/src/provider-payload.ts index 65d76f5f6e..2578b3a8ca 100644 --- a/gui/src/provider-payload.ts +++ b/gui/src/provider-payload.ts @@ -126,22 +126,14 @@ export type OpenAiEnableErrorKey = export class OpenAiEnableError extends Error { readonly i18nKey: OpenAiEnableErrorKey; - /** Optional management-API error text; callers may surface it when present. */ - readonly serverMessage?: string; - constructor(i18nKey: OpenAiEnableErrorKey, serverMessage?: string) { - super(serverMessage ?? i18nKey); + constructor(i18nKey: OpenAiEnableErrorKey) { + super(i18nKey); this.name = "OpenAiEnableError"; this.i18nKey = i18nKey; - this.serverMessage = serverMessage; } } -async function readProviderErrorMessage(response: Response): Promise { - const body = await response.json().catch(() => ({})) as { error?: unknown }; - return typeof body.error === "string" ? body.error : undefined; -} - export async function ensureOpenAiProvider( apiBase: string, state: "absent" | "disabled", @@ -154,14 +146,14 @@ export async function ensureOpenAiProvider( body: JSON.stringify({ disabled: false }), }); if (response.ok) return; - throw new OpenAiEnableError("codexAuth.enableOpenaiFailed", await readProviderErrorMessage(response)); + throw new OpenAiEnableError("codexAuth.enableOpenaiFailed"); } const presetsResponse = await fetchImpl(`${apiBase}/api/provider-presets`); if (!presetsResponse.ok) throw new OpenAiEnableError("codexAuth.openaiPresetLoadFailed"); const data = await presetsResponse.json() as { providers?: ProviderPostPreset[] }; const preset = data.providers?.find(provider => provider.id === "openai"); - if (!preset) throw new OpenAiEnableError("codexAuth.openaiPresetUnavailable"); + if (!preset?.provider) throw new OpenAiEnableError("codexAuth.openaiPresetUnavailable"); const response = await fetchImpl(`${apiBase}/api/providers`, { method: "POST", @@ -169,5 +161,5 @@ export async function ensureOpenAiProvider( body: JSON.stringify(buildReservedProviderPostBody(preset)), }); if (response.ok) return; - throw new OpenAiEnableError("codexAuth.enableOpenaiFailed", await readProviderErrorMessage(response)); + throw new OpenAiEnableError("codexAuth.enableOpenaiFailed"); } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 0cbd29b2fd..d84dbae5b8 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -270,6 +270,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } }); + test("disabled OpenAI recovery strips allowPrivateNetwork after successful re-enable", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "extra", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + allowPrivateNetwork: true, + disabled: true, + }, + extra: { + adapter: "openai-chat", + baseUrl: "https://extra.example.test/v1", + liveModels: false, + models: ["extra-model"], + }, + }, + }; + saveConfig(liveConfig); + const resolvedError = spyOn(destinationPolicy, "providerDestinationResolvedError") + .mockResolvedValue(null); + + try { + const request = new Request("http://127.0.0.1/api/providers?name=openai", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ disabled: false }), + }); + const response = await handleManagementAPI(request, new URL(request.url), liveConfig, { + refreshCodexCatalog: async () => undefined, + }); + expect(response?.status).toBe(200); + expect(liveConfig.providers.openai).toEqual({ + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }); + expect(Object.hasOwn(liveConfig.providers.openai as object, "allowPrivateNetwork")).toBe(false); + expect(Object.hasOwn(loadConfig().providers.openai as object, "allowPrivateNetwork")).toBe(false); + } finally { + resolvedError.mockRestore(); + } + }); + for (const [label, baseUrl] of [ ["uppercase host", "https://CHATGPT.com/backend-api/codex"], ["explicit :443 port", "https://chatgpt.com:443/backend-api/codex"], diff --git a/tests/provider-payload.test.ts b/tests/provider-payload.test.ts index 9dd586f145..0cb8ad26ad 100644 --- a/tests/provider-payload.test.ts +++ b/tests/provider-payload.test.ts @@ -130,6 +130,24 @@ describe("provider dashboard payload", () => { } }); + test("missing openai preset seed maps to a stable i18n key", async () => { + const fetchImpl = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname === "/api/provider-presets") { + return Response.json({ providers: [{ id: "openai" }] }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + try { + await ensureOpenAiProvider("http://localhost:10100", "absent", fetchImpl); + expect.unreachable("expected OpenAiEnableError"); + } catch (error) { + expect(error).toBeInstanceOf(OpenAiEnableError); + expect((error as OpenAiEnableError).i18nKey).toBe("codexAuth.openaiPresetUnavailable"); + } + }); + test("persists explicit API-key mode for built-in OAuth providers", () => { expect(buildProviderPayload({ name: "xai",