diff --git a/src/components/providers/EditProviderDialog.tsx b/src/components/providers/EditProviderDialog.tsx index f73131cc8..3c8cfcdd1 100644 --- a/src/components/providers/EditProviderDialog.tsx +++ b/src/components/providers/EditProviderDialog.tsx @@ -16,6 +16,7 @@ import { type AppId, type ManagedAuthProvider, } from "@/lib/api"; +import { extractCodexExperimentalBearerToken } from "@/utils/providerConfigUtils"; interface EditProviderDialogProps { open: boolean; @@ -29,6 +30,64 @@ interface EditProviderDialogProps { isProxyTakeover?: boolean; // 代理接管模式下不读取 live(避免显示被接管后的代理配置) } +const asRecord = (value: unknown): Record | null => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; + +const hasAuthMaterial = (value: unknown): boolean => { + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; +}; + +/** + * Rebuild the provider auth only for a current Codex provider's live snapshot. + * + * In official-auth-preservation mode, live config.toml owns the active + * provider bearer while the shared auth.json may belong to another provider or + * contain the user's ChatGPT login. Stored provider auth remains the template: + * this mirrors the backend switch-away backfill and avoids copying shared auth + * material into the provider row. DB snapshots and presets must keep their + * normal auth-first precedence. + */ +const reconcileCodexLiveAuth = ( + liveSettings: Record, + storedSettings: Record | null, + category: string | undefined, +): Record => { + if (category === "official") return liveSettings; + + const configText = + typeof liveSettings.config === "string" ? liveSettings.config : ""; + const bearer = extractCodexExperimentalBearerToken(configText); + if (!bearer) return liveSettings; + + const storedAuth = asRecord(storedSettings?.auth); + const authTemplate = storedAuth ?? asRecord(liveSettings.auth) ?? {}; + const hasProviderApiKey = + typeof authTemplate.OPENAI_API_KEY === "string" && + authTemplate.OPENAI_API_KEY.trim().length > 0; + const hasOauthLogin = Object.entries(authTemplate).some( + ([key, value]) => + key !== "auth_mode" && key !== "OPENAI_API_KEY" && hasAuthMaterial(value), + ); + + // Match should_restore_codex_provider_token_for_backfill: an OAuth-only + // provider must not be silently converted into an API-key provider. + if (hasOauthLogin && !hasProviderApiKey) return liveSettings; + + return { + ...liveSettings, + auth: { + ...authTemplate, + OPENAI_API_KEY: bearer, + }, + }; +}; + export function EditProviderDialog({ open, provider, @@ -181,10 +240,15 @@ export function EditProviderDialog({ }, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象 const initialSettingsConfig = useMemo(() => { - const base = (liveSettings ?? provider?.settingsConfig ?? {}) as Record< - string, - unknown - >; + const storedSettings = asRecord(provider?.settingsConfig); + const base = + appId === "codex" && liveSettings + ? reconcileCodexLiveAuth( + liveSettings, + storedSettings, + provider?.category, + ) + : (liveSettings ?? storedSettings ?? {}); // Codex 的 modelCatalog 是 cc-switch 私有字段,SSOT 在数据库。Live 的 config.toml // 仅在写入时投影出 model_catalog_json 指针;Codex.app 改写配置、代理接管/恢复周期、 @@ -205,7 +269,7 @@ export function EditProviderDialog({ } return base; - }, [liveSettings, provider?.settingsConfig, appId]); // 只依赖 settingsConfig,不依赖整个 provider + }, [liveSettings, provider?.settingsConfig, provider?.category, appId]); // 只依赖表单初始化所需字段,不依赖整个 provider // 固定 initialData,防止 provider 对象更新时重置表单 const initialData = useMemo(() => { diff --git a/src/components/providers/forms/hooks/useCodexConfigState.ts b/src/components/providers/forms/hooks/useCodexConfigState.ts index f1ead9c1e..a946b1ded 100644 --- a/src/components/providers/forms/hooks/useCodexConfigState.ts +++ b/src/components/providers/forms/hooks/useCodexConfigState.ts @@ -118,10 +118,6 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { const config = initialData.settingsConfig; if (typeof config === "object" && config !== null) { - // 设置 auth.json - const auth = (config as any).auth || {}; - setCodexAuthState(JSON.stringify(auth, null, 2)); - // 设置 config.toml const configStr = typeof (config as any).config === "string" @@ -129,6 +125,10 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) { : ""; setCodexConfigState(configStr); + // 设置 auth.json + const auth = (config as any).auth || {}; + setCodexAuthState(JSON.stringify(auth, null, 2)); + const modelCatalog = (config as any).modelCatalog; const rawCatalogModels = Array.isArray(modelCatalog?.models) ? modelCatalog.models diff --git a/tests/components/EditProviderDialog.test.tsx b/tests/components/EditProviderDialog.test.tsx index f631e6268..b78d5cb9b 100644 --- a/tests/components/EditProviderDialog.test.tsx +++ b/tests/components/EditProviderDialog.test.tsx @@ -206,6 +206,147 @@ describe("EditProviderDialog", () => { }); }); + it("uses the current Codex live bearer with the stored provider auth template", async () => { + const provider: Provider = { + id: "provider-a", + name: "Provider A", + category: "custom", + settingsConfig: { + auth: { + OPENAI_API_KEY: "sk-db-stale", + provider_note: "keep-me", + }, + config: + 'model_provider = "custom"\n[model_providers.custom]\nbase_url = "https://proxy.example/v1"\n', + }, + }; + const liveSettings = { + // Shared auth.json belongs to another provider / official login cache. + auth: { + OPENAI_API_KEY: "sk-shared-other-provider", + tokens: { account_id: "shared-account" }, + }, + config: + 'model_provider = "custom"\n[model_providers.custom]\nbase_url = "https://proxy.example/v1"\nexperimental_bearer_token = "sk-provider-a"\n', + }; + const handleSubmit = vi.fn().mockResolvedValue(undefined); + + apiMocks.getCurrent.mockResolvedValue(provider.id); + apiMocks.getLiveProviderSettings.mockResolvedValue(liveSettings); + + render( + , + ); + + const expectedSettings = { + ...liveSettings, + auth: { + OPENAI_API_KEY: "sk-provider-a", + provider_note: "keep-me", + }, + }; + + await waitFor(() => { + expect( + JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"), + ).toEqual(expectedSettings); + }); + + fireEvent.click(screen.getByRole("button", { name: "common.save" })); + + await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); + expect(handleSubmit.mock.calls[0][0].provider.settingsConfig).toEqual( + expectedSettings, + ); + }); + + it("does not convert an OAuth-only Codex provider into an API-key provider", async () => { + const provider: Provider = { + id: "oauth-provider", + name: "OAuth Provider", + category: "custom", + settingsConfig: { + auth: { + auth_mode: "chatgpt", + tokens: { account_id: "stored-account" }, + }, + config: 'model_provider = "custom"\n', + }, + }; + const liveSettings = { + auth: { + auth_mode: "chatgpt", + tokens: { account_id: "live-account" }, + }, + config: + 'model_provider = "custom"\nexperimental_bearer_token = "sk-route-only"\n', + }; + + apiMocks.getCurrent.mockResolvedValue(provider.id); + apiMocks.getLiveProviderSettings.mockResolvedValue(liveSettings); + + render( + , + ); + + await waitFor(() => { + expect( + JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"), + ).toEqual(liveSettings); + }); + }); + + it("does not let a stored bearer override a non-current Codex provider auth", async () => { + const provider: Provider = { + id: "provider-a", + name: "Provider A", + category: "custom", + settingsConfig: { + auth: { OPENAI_API_KEY: "sk-db-authoritative" }, + config: + 'model_provider = "custom"\nexperimental_bearer_token = "sk-leftover-live"\n', + }, + }; + const handleSubmit = vi.fn().mockResolvedValue(undefined); + + apiMocks.getCurrent.mockResolvedValue("provider-b"); + + render( + , + ); + + await waitFor(() => expect(apiMocks.getCurrent).toHaveBeenCalledTimes(1)); + expect(apiMocks.getLiveProviderSettings).not.toHaveBeenCalled(); + expect( + JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"), + ).toEqual(provider.settingsConfig); + + fireEvent.click(screen.getByRole("button", { name: "common.save" })); + + await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1)); + expect(handleSubmit.mock.calls[0][0].provider.settingsConfig).toEqual( + provider.settingsConfig, + ); + }); + it("代理接管中编辑 Codex 供应商时展示数据库配置而不是读取 live 代理配置", async () => { const provider: Provider = { id: "deepseek", diff --git a/tests/hooks/useCodexConfigState.bearer.test.ts b/tests/hooks/useCodexConfigState.bearer.test.ts new file mode 100644 index 000000000..82b59f29d --- /dev/null +++ b/tests/hooks/useCodexConfigState.bearer.test.ts @@ -0,0 +1,75 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { useCodexConfigState } from "@/components/providers/forms/hooks/useCodexConfigState"; + +// The hook is also used for stored providers and presets. Those inputs keep +// auth.OPENAI_API_KEY as their canonical credential; only EditProviderDialog's +// current-live boundary may lift a bearer into auth (#6414). +describe("useCodexConfigState bearer-token precedence", () => { + it("keeps stored auth authoritative when config also has a bearer", () => { + const initialData = { + settingsConfig: { + auth: { OPENAI_API_KEY: "sk-db-key" }, + config: + 'model_provider = "custom"\nmodel = "model-A"\nexperimental_bearer_token = "sk-leftover-live-key"\n', + }, + }; + + const { result } = renderHook(() => useCodexConfigState({ initialData })); + + expect(result.current.codexApiKey).toBe("sk-db-key"); + const savedAuth = JSON.parse(result.current.codexAuth); + expect(savedAuth.OPENAI_API_KEY).toBe("sk-db-key"); + }); + + it("falls back to the bearer for display without mutating an auth object that has no key", () => { + const initialData = { + settingsConfig: { + auth: { tokens: { account_id: "acc" } }, + config: + 'model_provider = "custom"\nmodel = "model-A"\nexperimental_bearer_token = "sk-real-key-A"\n', + }, + }; + + const { result } = renderHook(() => useCodexConfigState({ initialData })); + + expect(result.current.codexApiKey).toBe("sk-real-key-A"); + const savedAuth = JSON.parse(result.current.codexAuth); + expect(savedAuth.OPENAI_API_KEY).toBeUndefined(); + expect(savedAuth.tokens).toEqual({ account_id: "acc" }); + }); + + it("does not reconcile when the config has no bearer (default mode / manual live edits)", () => { + // Default mode keeps the active key in auth.json; the config has no bearer. + // A user's manual live edit (auth.json = "live-key") must be preserved + // exactly — this is the intentional backfill/capture behavior, and the + // reconciliation must not touch it. + const initialData = { + settingsConfig: { + auth: { OPENAI_API_KEY: "live-key" }, + config: 'model_provider = "custom"\nmodel = "model-A"\n', + }, + }; + + const { result } = renderHook(() => useCodexConfigState({ initialData })); + + expect(result.current.codexApiKey).toBe("live-key"); + const savedAuth = JSON.parse(result.current.codexAuth); + expect(savedAuth.OPENAI_API_KEY).toBe("live-key"); + }); + + it("keeps preset auth authoritative when reset config contains a bearer", () => { + const { result } = renderHook(() => useCodexConfigState({})); + + act(() => { + result.current.resetCodexConfig( + { OPENAI_API_KEY: "sk-preset-key" }, + 'model_provider = "custom"\nexperimental_bearer_token = "sk-leftover-key"\n', + ); + }); + + expect(result.current.codexApiKey).toBe("sk-preset-key"); + const savedAuth = JSON.parse(result.current.codexAuth); + expect(savedAuth.OPENAI_API_KEY).toBe("sk-preset-key"); + }); +});