Merge main and harden managed Codex account flow

This commit is contained in:
SaladDay
2026-07-18 10:06:58 +00:00
276 changed files with 32101 additions and 3292 deletions
+109
View File
@@ -7,7 +7,9 @@ import type { Provider } from "@/types";
const apiMocks = vi.hoisted(() => ({
add: vi.fn(),
update: vi.fn(),
ensureClaudeDesktopOfficialProvider: vi.fn(),
ensureCodexOfficialProvider: vi.fn(),
getAll: vi.fn(),
updateTrayMenu: vi.fn(),
}));
@@ -19,8 +21,11 @@ const uuidMocks = vi.hoisted(() => ({
vi.mock("@/lib/api", () => ({
providersApi: {
add: (...args: unknown[]) => apiMocks.add(...args),
update: (...args: unknown[]) => apiMocks.update(...args),
ensureClaudeDesktopOfficialProvider: (...args: unknown[]) =>
apiMocks.ensureClaudeDesktopOfficialProvider(...args),
ensureCodexOfficialProvider: (...args: unknown[]) =>
apiMocks.ensureCodexOfficialProvider(...args),
getAll: (...args: unknown[]) => apiMocks.getAll(...args),
updateTrayMenu: (...args: unknown[]) => apiMocks.updateTrayMenu(...args),
},
@@ -56,9 +61,11 @@ function createWrapper() {
beforeEach(() => {
apiMocks.add.mockReset().mockResolvedValue(true);
apiMocks.update.mockReset().mockResolvedValue(true);
apiMocks.ensureClaudeDesktopOfficialProvider
.mockReset()
.mockResolvedValue(true);
apiMocks.ensureCodexOfficialProvider.mockReset().mockResolvedValue(true);
apiMocks.getAll.mockReset().mockResolvedValue({});
apiMocks.updateTrayMenu.mockReset().mockResolvedValue(true);
uuidMocks.generateUUID.mockReset().mockReturnValue("generated-uuid");
@@ -133,4 +140,106 @@ describe("useAddProviderMutation", () => {
expect(apiMocks.add).not.toHaveBeenCalled();
expect(persistedProvider).toEqual(seedProvider);
});
it("persists a managed account binding onto the fixed Codex official seed", async () => {
const seedProvider: Provider = {
id: "codex-official",
name: "OpenAI Official",
settingsConfig: { auth: {}, config: "" },
category: "official",
};
apiMocks.getAll.mockResolvedValueOnce({
"codex-official": seedProvider,
});
const { wrapper } = createWrapper();
const { result } = renderHook(() => useAddProviderMutation("codex"), {
wrapper,
});
const persistedProvider = await act(async () =>
result.current.mutateAsync({
name: "OpenAI Official",
settingsConfig: { auth: {}, config: "" },
category: "official",
meta: {
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
},
ensureCodexOfficialSeed: true,
}),
);
expect(apiMocks.ensureCodexOfficialProvider).toHaveBeenCalledTimes(1);
expect(apiMocks.getAll).toHaveBeenCalledWith("codex");
expect(apiMocks.add).not.toHaveBeenCalled();
expect(apiMocks.update).toHaveBeenCalledWith(
expect.objectContaining({
id: "codex-official",
meta: {
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
},
}),
"codex",
);
expect(persistedProvider).toEqual(
expect.objectContaining({
id: "codex-official",
meta: expect.objectContaining({
authBinding: expect.objectContaining({
accountId: "acct-managed",
}),
}),
}),
);
});
it("clears a prior managed binding when Codex Official uses native login", async () => {
const seedProvider: Provider = {
id: "codex-official",
name: "OpenAI Official",
settingsConfig: { auth: {}, config: "" },
category: "official",
meta: {
providerType: "codex_oauth",
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
},
};
apiMocks.getAll.mockResolvedValueOnce({
"codex-official": seedProvider,
});
const { wrapper } = createWrapper();
const { result } = renderHook(() => useAddProviderMutation("codex"), {
wrapper,
});
const persistedProvider = await act(async () =>
result.current.mutateAsync({
name: "OpenAI Official",
settingsConfig: { auth: {}, config: "" },
category: "official",
meta: {},
ensureCodexOfficialSeed: true,
}),
);
expect(apiMocks.update).toHaveBeenCalledWith(
expect.objectContaining({
id: "codex-official",
meta: {},
}),
"codex",
);
expect(persistedProvider.meta).toEqual({});
});
});
+67
View File
@@ -0,0 +1,67 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useApiKeyState } from "@/components/providers/forms/hooks/useApiKeyState";
describe("useApiKeyState", () => {
it("shows and creates Claude API key for uncategorized edit providers", () => {
const onConfigChange = vi.fn();
const initialConfig = JSON.stringify({ env: {} }, null, 2);
const { result } = renderHook(() =>
useApiKeyState({
initialConfig,
onConfigChange,
selectedPresetId: null,
category: undefined,
appType: "claude",
}),
);
expect(result.current.showApiKey(initialConfig, true)).toBe(true);
act(() => {
result.current.handleApiKeyChange("sk-test");
});
const updated = JSON.parse(onConfigChange.mock.calls.at(-1)?.[0]);
expect(updated.env.ANTHROPIC_AUTH_TOKEN).toBe("sk-test");
});
it("keeps official and cloud provider edit behavior conservative", () => {
const initialConfig = JSON.stringify({ env: {} }, null, 2);
const officialConfigChange = vi.fn();
const official = renderHook(() =>
useApiKeyState({
initialConfig,
onConfigChange: officialConfigChange,
selectedPresetId: null,
category: "official",
appType: "claude",
}),
);
expect(official.result.current.showApiKey(initialConfig, true)).toBe(false);
act(() => {
official.result.current.handleApiKeyChange("sk-official");
});
expect(officialConfigChange).toHaveBeenLastCalledWith(initialConfig);
const cloudProviderConfigChange = vi.fn();
const cloudProvider = renderHook(() =>
useApiKeyState({
initialConfig,
onConfigChange: cloudProviderConfigChange,
selectedPresetId: null,
category: "cloud_provider",
appType: "claude",
}),
);
expect(cloudProvider.result.current.showApiKey(initialConfig, true)).toBe(
false,
);
act(() => {
cloudProvider.result.current.handleApiKeyChange("sk-cloud");
});
expect(cloudProviderConfigChange).toHaveBeenLastCalledWith(initialConfig);
});
});
+102 -3
View File
@@ -6,6 +6,7 @@ import { useGeminiCommonConfig } from "@/components/providers/forms/hooks/useGem
const getCommonConfigSnippetMock = vi.fn();
const setCommonConfigSnippetMock = vi.fn();
const extractCommonConfigSnippetMock = vi.fn();
const updateTomlCommonConfigSnippetMock = vi.fn();
vi.mock("@/lib/api", () => ({
configApi: {
@@ -15,6 +16,8 @@ vi.mock("@/lib/api", () => ({
setCommonConfigSnippetMock(...args),
extractCommonConfigSnippet: (...args: unknown[]) =>
extractCommonConfigSnippetMock(...args),
updateTomlCommonConfigSnippet: (...args: unknown[]) =>
updateTomlCommonConfigSnippetMock(...args),
},
}));
@@ -23,6 +26,9 @@ describe("common config snippet saving", () => {
getCommonConfigSnippetMock.mockResolvedValue("");
setCommonConfigSnippetMock.mockResolvedValue(undefined);
extractCommonConfigSnippetMock.mockResolvedValue("");
updateTomlCommonConfigSnippetMock.mockImplementation(
async (configToml: string) => configToml,
);
});
it("does not persist an invalid Codex common config snippet", async () => {
@@ -36,9 +42,9 @@ describe("common config snippet saving", () => {
await waitFor(() => expect(result.current.isLoading).toBe(false));
let saved = false;
act(() => {
saved = result.current.handleCommonConfigSnippetChange(
let saved = true;
await act(async () => {
saved = await result.current.handleCommonConfigSnippetChange(
"base_url = https://bad.example/v1",
);
});
@@ -49,6 +55,99 @@ describe("common config snippet saving", () => {
expect(result.current.commonConfigError).toContain("invalid value");
});
it("discards stale toggle results when a newer toggle finishes first", async () => {
getCommonConfigSnippetMock.mockResolvedValue(
"[tui]\nnotifications = true\n",
);
const onConfigChange = vi.fn();
const { result } = renderHook(() =>
useCodexCommonConfig({
codexConfig: 'model = "gpt-5"',
onConfigChange,
initialData: { settingsConfig: { config: 'model = "gpt-5"' } },
initialEnabled: false,
}),
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
await waitFor(() => expect(result.current.useCommonConfig).toBe(false));
// 第一次调用(勾选 on 的 merge)挂起,第二次(取消勾选的剥离)立即返回:
// 模拟后端乱序完成
let resolveMerge: ((value: string) => void) | undefined;
updateTomlCommonConfigSnippetMock
.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
resolveMerge = resolve;
}),
)
.mockImplementationOnce(async (configToml: string) => configToml);
await act(async () => {
const mergePending = result.current.handleCommonConfigToggle(true);
const removeDone = result.current.handleCommonConfigToggle(false);
await removeDone;
// on 的合并结果此时才姗姗来迟——必须被序号守卫丢弃
resolveMerge?.('model = "gpt-5"\n\n[tui]\nnotifications = true\n');
await mergePending;
});
// 用户最后一次操作是 off:过期的 on 结果不得翻转开关或改写配置
expect(result.current.useCommonConfig).toBe(false);
const lastConfig = onConfigChange.mock.calls.at(-1)?.[0] as string;
expect(lastConfig).not.toContain("[tui]");
});
it("discards async merge results when the user edited the config while in flight", async () => {
getCommonConfigSnippetMock.mockResolvedValue(
"[tui]\nnotifications = true\n",
);
const initialData = { settingsConfig: { config: 'model = "gpt-5"' } };
const onConfigChange = vi.fn();
const { result, rerender } = renderHook(
({ config }: { config: string }) =>
useCodexCommonConfig({
codexConfig: config,
onConfigChange,
initialData,
initialEnabled: false,
}),
{ initialProps: { config: 'model = "gpt-5"' } },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
await waitFor(() => expect(result.current.useCommonConfig).toBe(false));
let resolveMerge: ((value: string) => void) | undefined;
updateTomlCommonConfigSnippetMock.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
resolveMerge = resolve;
}),
);
let togglePending: Promise<void> = Promise.resolve();
act(() => {
togglePending = result.current.handleCommonConfigToggle(true);
});
// merge 在飞期间,用户在编辑器里手动改了 config(不经过 hook
// 序号不变,只有 codexConfig prop 变化)
rerender({ config: 'model = "gpt-6-user-edit"' });
await act(async () => {
resolveMerge?.('model = "gpt-5"\n\n[tui]\nnotifications = true\n');
await togglePending;
});
// 基于陈旧基线的合并结果必须被丢弃,不得覆盖用户的手动编辑
expect(onConfigChange).not.toHaveBeenCalled();
expect(result.current.useCommonConfig).toBe(false);
});
it("does not persist an invalid Gemini common config snippet", async () => {
const onEnvChange = vi.fn();
const { result } = renderHook(() =>
@@ -48,6 +48,7 @@ const createSettings = (
enableClaudePluginIntegration: false,
claudeConfigDir: "/claude/custom",
codexConfigDir: "/codex/custom",
grokConfigDir: "/grok/custom",
language: "zh",
...overrides,
});
@@ -68,6 +69,7 @@ describe("useDirectorySettings", () => {
if (app === "claude") return "/remote/claude";
if (app === "codex") return "/remote/codex";
if (app === "gemini") return "/remote/gemini";
if (app === "grokbuild") return "/remote/grok";
if (app === "opencode") return "/remote/opencode";
if (app === "openclaw") return "/remote/openclaw";
return "/remote/hermes";
@@ -90,6 +92,7 @@ describe("useDirectorySettings", () => {
claude: "/remote/claude",
codex: "/remote/codex",
gemini: "/remote/gemini",
grokbuild: "/remote/grok",
opencode: "/remote/opencode",
openclaw: "/remote/openclaw",
hermes: "/remote/hermes",
@@ -249,6 +252,7 @@ describe("useDirectorySettings", () => {
claude: "/server/claude",
codex: "/server/codex",
gemini: "/server/gemini",
grokbuild: "/server/grok",
opencode: "/server/opencode",
openclaw: "/server/openclaw",
});
@@ -257,6 +261,7 @@ describe("useDirectorySettings", () => {
expect(result.current.resolvedDirs.claude).toBe("/server/claude");
expect(result.current.resolvedDirs.codex).toBe("/server/codex");
expect(result.current.resolvedDirs.gemini).toBe("/server/gemini");
expect(result.current.resolvedDirs.grokbuild).toBe("/server/grok");
expect(result.current.resolvedDirs.opencode).toBe("/server/opencode");
expect(result.current.resolvedDirs.openclaw).toBe("/server/openclaw");
});
+38
View File
@@ -17,6 +17,7 @@ describe("useModelState", () => {
ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: "DeepSeek V4 Pro",
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2",
ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: "Kimi K2",
CLAUDE_CODE_SUBAGENT_MODEL: "subagent-model[1M]",
},
});
@@ -34,6 +35,7 @@ describe("useModelState", () => {
expect(result.current.defaultOpusModelName).toBe("Kimi K2");
expect(result.current.defaultHaikuModel).toBe("legacy-small");
expect(result.current.defaultHaikuModelName).toBe("legacy-small");
expect(result.current.subagentModel).toBe("subagent-model[1M]");
});
it("writes and clears role display-name env fields without changing model mapping", () => {
@@ -94,6 +96,42 @@ describe("useModelState", () => {
expect(result.current.defaultSonnetModelName).toBe("deepseek-v4-pro");
});
it("writes and clears the Claude Code subagent model env field", () => {
let latestConfig = JSON.stringify({
env: {
ANTHROPIC_MODEL: "fallback-model",
},
});
const onConfigChange = vi.fn((config: string) => {
latestConfig = config;
});
const { result } = renderHook(() =>
useModelState({
settingsConfig: latestConfig,
onConfigChange,
}),
);
act(() => {
result.current.handleModelChange(
"CLAUDE_CODE_SUBAGENT_MODEL",
"subagent-model[1M]",
);
});
let env = JSON.parse(latestConfig).env;
expect(env.ANTHROPIC_MODEL).toBe("fallback-model");
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("subagent-model[1M]");
act(() => {
result.current.handleModelChange("CLAUDE_CODE_SUBAGENT_MODEL", "");
});
env = JSON.parse(latestConfig).env;
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBeUndefined();
});
it("normalizes Claude Code 1M markers for UI toggles", () => {
expect(hasClaudeOneMMarker("deepseek-v4-pro[1m]")).toBe(true);
expect(hasClaudeOneMMarker("deepseek-v4-pro [1M] ")).toBe(true);
+132
View File
@@ -0,0 +1,132 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useOpencodeFormState } from "@/components/providers/forms/hooks/useOpencodeFormState";
const renderOpencodeFormState = (
initialSettingsConfig: Record<string, unknown>,
) => {
let settingsConfig = JSON.stringify(initialSettingsConfig);
const onSettingsConfigChange = vi.fn((nextConfig: string) => {
settingsConfig = nextConfig;
});
const hook = renderHook(() =>
useOpencodeFormState({
appId: "opencode",
initialData: { settingsConfig: initialSettingsConfig },
onSettingsConfigChange,
getSettingsConfig: () => settingsConfig,
}),
);
return {
...hook,
onSettingsConfigChange,
getSettingsConfig: () => settingsConfig,
};
};
describe("useOpencodeFormState", () => {
it("hydrates provider headers from options", () => {
const { result } = renderOpencodeFormState({
npm: "@ai-sdk/openai-compatible",
options: {
headers: {
"HTTP-Referer": "https://cc-switch.app",
"X-Title": "CC Switch",
},
},
models: {},
});
expect(result.current.opencodeHeaders).toEqual({
"HTTP-Referer": "https://cc-switch.app",
"X-Title": "CC Switch",
});
});
it("writes provider headers to options", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai-compatible",
options: {},
models: {},
});
act(() => {
result.current.handleOpencodeHeadersChange({
"X-Title": "CC Switch",
});
});
expect(JSON.parse(getSettingsConfig()).options.headers).toEqual({
"X-Title": "CC Switch",
});
});
it("removes options.headers when all provider headers are removed", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai-compatible",
options: {
headers: {
"X-Title": "CC Switch",
},
},
models: {},
});
act(() => {
result.current.handleOpencodeHeadersChange({});
});
expect(JSON.parse(getSettingsConfig()).options.headers).toBeUndefined();
});
it("preserves legitimate headers whose names start with header-", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai-compatible",
options: {
headers: {
"header-version": "v1",
"X-Title": "Old",
},
},
models: {},
});
act(() => {
result.current.handleOpencodeHeadersChange({
"header-version": "v1",
"X-Title": "New",
});
});
expect(JSON.parse(getSettingsConfig()).options.headers).toEqual({
"header-version": "v1",
"X-Title": "New",
});
});
it("preserves legitimate options whose names start with option-", () => {
const { result, getSettingsConfig } = renderOpencodeFormState({
npm: "@ai-sdk/openai-compatible",
options: {
"option-mode": "legacy",
timeout: 100,
},
models: {},
});
act(() => {
result.current.handleOpencodeExtraOptionsChange({
"option-mode": "legacy",
timeout: "200",
"draft-option:123": "",
});
});
expect(JSON.parse(getSettingsConfig()).options).toEqual({
"option-mode": "legacy",
timeout: 200,
});
});
});
+118
View File
@@ -242,6 +242,124 @@ describe("useProviderActions", () => {
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
});
it("warns when switching a Codex Anthropic-format provider without proxy", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
const { wrapper } = createWrapper();
const provider = createProvider({
category: "custom",
meta: { apiFormat: "anthropic" },
});
const { result } = renderHook(() => useProviderActions("codex", false), {
wrapper,
});
await act(async () => {
await result.current.switchProvider(provider);
});
expect(toastWarningMock).toHaveBeenCalledWith(
expect.stringContaining("Anthropic Messages"),
);
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
});
it("warns for Grok providers that require the Responses router", async () => {
switchProviderMutateAsync.mockResolvedValue(undefined);
const { wrapper } = createWrapper();
const providers = [
createProvider({
id: "grok-chat",
category: "custom",
meta: { apiFormat: "openai_chat" },
}),
createProvider({
id: "grok-anthropic",
category: "custom",
meta: { apiFormat: "anthropic" },
}),
createProvider({
id: "grok-full-url",
category: "custom",
meta: { isFullUrl: true },
}),
];
const { result } = renderHook(
() => useProviderActions("grokbuild", false),
{ wrapper },
);
for (const provider of providers) {
await act(async () => {
await result.current.switchProvider(provider);
});
}
expect(toastWarningMock).toHaveBeenCalledTimes(3);
expect(switchProviderMutateAsync).toHaveBeenCalledTimes(3);
});
it("allows the built-in Codex official provider during takeover", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
const { wrapper } = createWrapper();
const provider = createProvider({
id: "codex-official",
category: "official",
});
const { result } = renderHook(
() => useProviderActions("codex", true, true),
{ wrapper },
);
await act(async () => {
await result.current.switchProvider(provider);
});
expect(switchProviderMutateAsync).toHaveBeenCalledWith("codex-official");
expect(toastErrorMock).not.toHaveBeenCalled();
});
it("continues blocking other official providers during takeover", async () => {
const { wrapper } = createWrapper();
const provider = createProvider({
id: "claude-official",
category: "official",
});
const { result } = renderHook(
() => useProviderActions("claude", true, true),
{ wrapper },
);
await act(async () => {
await result.current.switchProvider(provider);
});
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
expect(toastErrorMock).toHaveBeenCalledTimes(1);
});
it("does not grant routing capability to a UUID Codex official copy", async () => {
const { wrapper } = createWrapper();
const provider = createProvider({
id: "generated-uuid",
category: "official",
});
const { result } = renderHook(
() => useProviderActions("codex", true, true),
{ wrapper },
);
await act(async () => {
await result.current.switchProvider(provider);
});
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
expect(toastErrorMock).toHaveBeenCalledTimes(1);
});
it("should sync plugin config when switching Claude provider with integration enabled", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
settingsApiGetMock.mockResolvedValueOnce({
+2 -1
View File
@@ -81,6 +81,7 @@ describe("useProxyStatus", () => {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
openclaw: false,
});
@@ -115,4 +116,4 @@ describe("useProxyStatus", () => {
{ closeButton: true },
);
});
});
});