mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
* 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 4ddf304eca458a1eadcf93f5851579c7dde94828. * 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
563 lines
16 KiB
TypeScript
563 lines
16 KiB
TypeScript
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from "@testing-library/react";
|
|
import { useEffect } from "react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { Provider } from "@/types";
|
|
|
|
const apiMocks = vi.hoisted(() => ({
|
|
getCurrent: vi.fn(),
|
|
getLiveProviderSettings: vi.fn(),
|
|
getOpenClawLiveProvider: vi.fn(),
|
|
}));
|
|
let mockFormReady = true;
|
|
let mockCodexManagedAccountSelected = false;
|
|
let submitReadyCallbacks: Array<(isReady: boolean) => void> = [];
|
|
|
|
vi.mock("@/lib/api", () => ({
|
|
providersApi: {
|
|
getCurrent: apiMocks.getCurrent,
|
|
},
|
|
vscodeApi: {
|
|
getLiveProviderSettings: apiMocks.getLiveProviderSettings,
|
|
},
|
|
openclawApi: {
|
|
getLiveProvider: apiMocks.getOpenClawLiveProvider,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/components/common/FullScreenPanel", () => ({
|
|
FullScreenPanel: ({
|
|
isOpen,
|
|
children,
|
|
footer,
|
|
}: {
|
|
isOpen: boolean;
|
|
children: React.ReactNode;
|
|
footer?: React.ReactNode;
|
|
}) =>
|
|
isOpen ? (
|
|
<div>
|
|
<div>{children}</div>
|
|
<div>{footer}</div>
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
vi.mock("@/components/providers/forms/ProviderForm", () => ({
|
|
ProviderForm: ({
|
|
initialData,
|
|
onSubmit,
|
|
onSubmitReadyChange,
|
|
onManageAuthAccounts,
|
|
isProxyTakeover,
|
|
}: {
|
|
initialData: {
|
|
name?: string;
|
|
websiteUrl?: string;
|
|
notes?: string;
|
|
settingsConfig?: Record<string, unknown>;
|
|
meta?: Record<string, unknown>;
|
|
icon?: string;
|
|
iconColor?: string;
|
|
};
|
|
onSubmit: (values: {
|
|
name: string;
|
|
websiteUrl: string;
|
|
notes?: string;
|
|
settingsConfig: string;
|
|
meta?: Record<string, unknown>;
|
|
icon?: string;
|
|
iconColor?: string;
|
|
}) => void;
|
|
onSubmitReadyChange?: (isReady: boolean) => void;
|
|
onManageAuthAccounts?: (target: "codex_oauth") => void;
|
|
isProxyTakeover?: boolean;
|
|
appId?: string;
|
|
}) => {
|
|
useEffect(() => {
|
|
if (onSubmitReadyChange) {
|
|
submitReadyCallbacks.push(onSubmitReadyChange);
|
|
onSubmitReadyChange(mockFormReady);
|
|
}
|
|
}, [onSubmitReadyChange]);
|
|
return (
|
|
<form
|
|
id="provider-form"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
onSubmit({
|
|
name: initialData.name ?? "",
|
|
websiteUrl: initialData.websiteUrl ?? "",
|
|
notes: initialData.notes,
|
|
settingsConfig: JSON.stringify(initialData.settingsConfig ?? {}),
|
|
meta: mockCodexManagedAccountSelected
|
|
? {
|
|
...(initialData.meta ?? {}),
|
|
providerType: "codex_oauth",
|
|
authBinding: {
|
|
source: "managed_account",
|
|
authProvider: "codex_oauth",
|
|
accountId: "acct-managed",
|
|
},
|
|
}
|
|
: initialData.meta,
|
|
icon: initialData.icon,
|
|
iconColor: initialData.iconColor,
|
|
});
|
|
}}
|
|
>
|
|
<output data-testid="settings-config">
|
|
{JSON.stringify(initialData.settingsConfig ?? {})}
|
|
</output>
|
|
<output data-testid="is-proxy-takeover">
|
|
{isProxyTakeover ? "true" : "false"}
|
|
</output>
|
|
<button
|
|
type="button"
|
|
onClick={() => onManageAuthAccounts?.("codex_oauth")}
|
|
>
|
|
manage-auth
|
|
</button>
|
|
</form>
|
|
);
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/components/providers/AuthSettingsPanel", () => ({
|
|
AuthSettingsPanel: ({ target }: { target: string | null }) =>
|
|
target ? <div data-testid="auth-settings-panel">{target}</div> : null,
|
|
}));
|
|
|
|
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
|
|
|
|
describe("EditProviderDialog", () => {
|
|
beforeEach(() => {
|
|
mockFormReady = true;
|
|
mockCodexManagedAccountSelected = false;
|
|
submitReadyCallbacks = [];
|
|
apiMocks.getCurrent.mockReset();
|
|
apiMocks.getLiveProviderSettings.mockReset();
|
|
apiMocks.getOpenClawLiveProvider.mockReset();
|
|
});
|
|
|
|
it("保留 Codex 数据库中的 modelCatalog,避免 live 配置缺字段时清空模型映射", async () => {
|
|
const dbModelCatalog = {
|
|
models: [
|
|
{
|
|
model: "deepseek-v4-flash",
|
|
displayName: "DeepSeek V4 Flash",
|
|
contextWindow: 1000000,
|
|
},
|
|
],
|
|
};
|
|
const provider: Provider = {
|
|
id: "deepseek",
|
|
name: "DeepSeek",
|
|
category: "aggregator",
|
|
settingsConfig: {
|
|
auth: {
|
|
OPENAI_API_KEY: "db-key",
|
|
},
|
|
config: 'model_provider = "custom"\nmodel = "deepseek-v4-flash"\n',
|
|
modelCatalog: dbModelCatalog,
|
|
},
|
|
};
|
|
const liveSettings = {
|
|
auth: {
|
|
OPENAI_API_KEY: "live-key",
|
|
},
|
|
config: 'model_provider = "custom"\nmodel = "deepseek-v4-pro"\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"
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"),
|
|
).toEqual({
|
|
...liveSettings,
|
|
modelCatalog: dbModelCatalog,
|
|
});
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
|
|
|
|
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
|
|
expect(handleSubmit.mock.calls[0][0].provider.settingsConfig).toEqual({
|
|
...liveSettings,
|
|
modelCatalog: dbModelCatalog,
|
|
});
|
|
});
|
|
|
|
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",
|
|
name: "DeepSeek",
|
|
category: "custom",
|
|
settingsConfig: {
|
|
auth: {
|
|
OPENAI_API_KEY: "db-key",
|
|
},
|
|
config:
|
|
'model_provider = "custom"\n[model_providers.custom]\nbase_url = "https://api.deepseek.com/v1"\n',
|
|
},
|
|
};
|
|
|
|
apiMocks.getCurrent.mockResolvedValue(provider.id);
|
|
apiMocks.getLiveProviderSettings.mockResolvedValue({
|
|
auth: {
|
|
OPENAI_API_KEY: "PROXY_MANAGED",
|
|
},
|
|
config:
|
|
'model_provider = "custom"\n[model_providers.custom]\nbase_url = "http://127.0.0.1:15721/v1"\nexperimental_bearer_token = "PROXY_MANAGED"\n',
|
|
});
|
|
|
|
render(
|
|
<EditProviderDialog
|
|
open
|
|
provider={provider}
|
|
onOpenChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
appId="codex"
|
|
isProxyTakeover
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("is-proxy-takeover").textContent).toBe("true");
|
|
});
|
|
|
|
expect(apiMocks.getLiveProviderSettings).not.toHaveBeenCalled();
|
|
expect(
|
|
JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"),
|
|
).toEqual(provider.settingsConfig);
|
|
});
|
|
|
|
it("clears the nested auth panel before the dialog reopens", async () => {
|
|
const provider: Provider = {
|
|
id: "official",
|
|
name: "OpenAI Official",
|
|
settingsConfig: { auth: {}, config: "" },
|
|
};
|
|
const props = {
|
|
provider,
|
|
onOpenChange: vi.fn(),
|
|
onSubmit: vi.fn(),
|
|
appId: "codex" as const,
|
|
};
|
|
const { rerender } = render(<EditProviderDialog open {...props} />);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "manage-auth" }));
|
|
expect(screen.getByTestId("auth-settings-panel")).toHaveTextContent(
|
|
"codex_oauth",
|
|
);
|
|
|
|
rerender(<EditProviderDialog open={false} {...props} />);
|
|
rerender(<EditProviderDialog open {...props} />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.queryByTestId("auth-settings-panel"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("keeps an unbound Codex Official provider ID unchanged", async () => {
|
|
apiMocks.getCurrent.mockResolvedValue(null);
|
|
const onSubmit = vi.fn();
|
|
const provider: Provider = {
|
|
id: "legacy-unbound-official",
|
|
name: "Legacy OpenAI Official",
|
|
category: "official",
|
|
settingsConfig: { auth: {}, config: "" },
|
|
};
|
|
|
|
render(
|
|
<EditProviderDialog
|
|
open
|
|
provider={provider}
|
|
onOpenChange={vi.fn()}
|
|
onSubmit={onSubmit}
|
|
appId="codex"
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
|
|
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
expect(onSubmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
originalId: "legacy-unbound-official",
|
|
provider: expect.objectContaining({ id: "legacy-unbound-official" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps the fixed Codex provider ID when an account is bound", async () => {
|
|
mockCodexManagedAccountSelected = true;
|
|
apiMocks.getCurrent.mockResolvedValue(null);
|
|
const onSubmit = vi.fn();
|
|
const provider: Provider = {
|
|
id: "codex-official",
|
|
name: "OpenAI Official",
|
|
category: "official",
|
|
settingsConfig: { auth: {}, config: "" },
|
|
};
|
|
|
|
render(
|
|
<EditProviderDialog
|
|
open
|
|
provider={provider}
|
|
onOpenChange={vi.fn()}
|
|
onSubmit={onSubmit}
|
|
appId="codex"
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
|
|
|
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
const submitted = onSubmit.mock.calls[0][0];
|
|
expect(submitted.originalId).toBe("codex-official");
|
|
expect(submitted.provider.id).toBe("codex-official");
|
|
expect(submitted.provider.meta?.authBinding).toEqual({
|
|
source: "managed_account",
|
|
authProvider: "codex_oauth",
|
|
accountId: "acct-managed",
|
|
});
|
|
});
|
|
|
|
it("编辑 Pi 供应商时保留通用元数据", async () => {
|
|
const provider: Provider = {
|
|
id: "pi-provider",
|
|
name: "Pi Provider",
|
|
settingsConfig: {
|
|
baseUrl: "https://api.example.com/v1",
|
|
models: [{ id: "model" }],
|
|
},
|
|
meta: {
|
|
isPartner: true,
|
|
endpointAutoSelect: true,
|
|
custom_endpoints: {
|
|
"https://failover.example.com/v1": {
|
|
url: "https://failover.example.com/v1",
|
|
addedAt: 1,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const handleSubmit = vi.fn().mockResolvedValue(undefined);
|
|
|
|
render(
|
|
<EditProviderDialog
|
|
open
|
|
provider={provider}
|
|
onOpenChange={vi.fn()}
|
|
onSubmit={handleSubmit}
|
|
appId="pi"
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
|
|
|
|
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
|
|
expect(handleSubmit.mock.calls[0][0].provider.meta).toMatchObject({
|
|
isPartner: true,
|
|
});
|
|
expect(handleSubmit.mock.calls[0][0]).not.toHaveProperty(
|
|
"expectedSettingsConfig",
|
|
);
|
|
});
|
|
|
|
it("重新打开 Pi 编辑表单后忽略上一轮的就绪回调", async () => {
|
|
const provider: Provider = {
|
|
id: "pi-provider",
|
|
name: "Pi Provider",
|
|
settingsConfig: { models: [{ id: "model" }] },
|
|
};
|
|
const props = {
|
|
provider,
|
|
onOpenChange: vi.fn(),
|
|
onSubmit: vi.fn(),
|
|
appId: "pi" as const,
|
|
};
|
|
const { rerender } = render(<EditProviderDialog open {...props} />);
|
|
|
|
const saveButton = await screen.findByRole("button", {
|
|
name: "common.save",
|
|
});
|
|
await waitFor(() => expect(saveButton).toBeEnabled());
|
|
const staleCallback = submitReadyCallbacks.at(-1);
|
|
expect(staleCallback).toBeDefined();
|
|
|
|
rerender(<EditProviderDialog open={false} {...props} />);
|
|
mockFormReady = false;
|
|
rerender(<EditProviderDialog open {...props} />);
|
|
const reopenedButton = await screen.findByRole("button", {
|
|
name: "common.save",
|
|
});
|
|
await waitFor(() => expect(reopenedButton).toBeDisabled());
|
|
|
|
act(() => staleCallback?.(true));
|
|
expect(reopenedButton).toBeDisabled();
|
|
});
|
|
});
|