fix(codex): reconcile edit form key with live bearer token (#6534)

* fix(codex): keep provider's own key on switch-away backfill

When switching away from a Codex provider, cc-switch backfills the
outgoing provider's stored config from the live ~/.codex files so that
in-app changes are captured. restore_codex_provider_token_for_backfill
decides what to persist as the provider's auth.OPENAI_API_KEY:

- When the live config.toml carries a per-provider
  experimental_bearer_token, that token is provider-scoped and is lifted
  back into the stored auth. Correct.

- When it does NOT (the default preserve_codex_official_auth_on_switch
  = false mode keeps the active key in the shared auth.json), the
  function returned early and the caller adopted the live auth.json
  wholesale as the provider's stored auth.

The problem: auth.json is a single-slot shared file with no provider
identity; it always holds the most-recently-activated provider's key.
Any time it holds ANOTHER provider's key (after a proxy-takeover
backup/restore cycle, an in-app ChatGPT login overwriting auth.json,
a cloud-sync current/live divergence, or any current-vs-live mismatch),
the switch-away backfill overwrote the outgoing provider's stored key
with another's. Repeated switches made keys silently converge across
providers that share a base URL, surfacing as "model xxx not found"
because the wrong key reached a model it was not entitled to.

Fix: in the no-bearer branch, preserve the provider's own DB-stored
auth instead of adopting the shared live auth.json. Live config.toml
changes (model/base_url/mcp/env) are still captured by the caller; only
the credential slot is no longer taken from the shared file. This
mirrors the #6277 restore-side rule of never letting the shared live
credential clobber per-provider storage.

Adds two regression tests:
- backfill_keeps_provider_own_key_when_live_auth_holds_another_key
  (the failing case under the old code)
- backfill_restores_key_from_live_bearer_token_when_present
  (positive control for the already-correct bearer path)

Fixes #6414.

* Revert "fix(codex): keep provider's own key on switch-away backfill"

This reverts commit 4ddf304eca.

* fix(codex): reconcile edit form key with live bearer token

In bearer-token mode (preserveCodexOfficialAuthOnSwitch enabled — used to
keep a ChatGPT login while routing third-party providers), switching to a
provider writes its key to config.toml's experimental_bearer_token and
preserves the shared ~/.codex/auth.json. auth.json is a single shared slot
with no provider identity, so it may hold ANOTHER provider's stale key
(left over from a previous default-mode switch or a different provider).

When the user then opened the edit dialog for the current provider,
EditProviderDialog loaded liveSettings as the form base, and
useCodexConfigState initialized codexAuth from the stale auth.json while
pickCodexApiKey preferred auth.OPENAI_API_KEY over the bearer token. The
form therefore displayed — and on save persisted — the stale key back
into the provider's DB record. Repeated edits made keys silently converge
across providers that share a base URL, surfacing as "model xxx not found"
once the wrong key reached a model it was not entitled to (#6414).

Fix: when loading Codex config into the form, lift the config's
experimental_bearer_token into auth.OPENAI_API_KEY (when present and
differing) so both the displayed key and the saved auth carry the
correct per-provider key. This mirrors the backend
restore_codex_provider_token_for_backfill lift and is a no-op when the
config has no bearer (default mode keeps auth.json as the active key
slot, and manual live auth edits are preserved exactly — covered by
existing tests).

Adds 4 regression tests in tests/hooks/useCodexConfigState.bearer.test.ts.

Fixes #6414.

* style: prettier-format useCodexConfigState.ts

Fixes the frontend CI "Check formatting" failure on the previous commit.
Pure formatting (collapses a multi-line call to one line); no behavior
change.

* fix(codex): scope bearer reconciliation to live edits
This commit is contained in:
Thefool
2026-08-26 17:21:30 +08:00
committed by GitHub
parent bd15ea1193
commit bbe8bb93ab
4 changed files with 289 additions and 9 deletions
@@ -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<string, unknown> | null =>
typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string, unknown>,
storedSettings: Record<string, unknown> | null,
category: string | undefined,
): Record<string, unknown> => {
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(() => {
@@ -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
@@ -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(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={handleSubmit}
appId="codex"
/>,
);
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(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
appId="codex"
/>,
);
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(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={handleSubmit}
appId="codex"
/>,
);
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",
@@ -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");
});
});