From 0960fd7179d966b380d97ca45587d1d203675f4a Mon Sep 17 00:00:00 2001 From: Eunknight <403961282@qq.com> Date: Sun, 31 May 2026 23:37:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Claude=20Desktop=20=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E6=B7=BB=E5=8A=A0=E6=8A=A5=E9=94=99?= =?UTF-8?q?=20#3402=20(#3405)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Claude Desktop 官方供应商添加时缺少 ANTHROPIC_BASE_URL 报错 根因:前端 mutation 为 claude-desktop 生成随机 UUID 作为 provider id, 后端 is_official_provider 通过 id 匹配跳过校验,随机 UUID 不匹配导致 走入普通 direct 模式校验并要求 ANTHROPIC_BASE_URL。 修复: - 前端:claude-desktop + category=official 时使用固定 id "claude-desktop-official" - 后端:validate_provider / validate_direct_provider / validate_proxy_provider / apply_provider_to_paths 增加 category=="official" 兜底检查 Fixes #3402 * fix: restrict Claude Desktop official provider detection * fix: add Claude Desktop official provider via seed --------- Co-authored-by: 金恩光 Co-authored-by: Jason --- src-tauri/src/claude_desktop_config.rs | 3 +- src-tauri/src/commands/provider.rs | 11 ++ src-tauri/src/lib.rs | 1 + .../providers/AddProviderDialog.tsx | 12 ++ src/hooks/useProviderActions.ts | 1 + src/lib/api/providers.ts | 4 + src/lib/query/mutations.ts | 20 ++- tests/hooks/useAddProviderMutation.test.tsx | 136 ++++++++++++++++++ 8 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 tests/hooks/useAddProviderMutation.test.tsx diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index 3739da2cd..5595e9416 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -1947,12 +1947,13 @@ mod tests { let direct = direct_provider("direct"); assert!(is_compatible_direct_provider(&direct)); - let claude_official = Provider::with_id( + let mut claude_official = Provider::with_id( "claude-official".to_string(), "Claude Official".to_string(), json!({"env": {}}), Some("https://www.anthropic.com/claude-code".to_string()), ); + claude_official.category = Some("official".to_string()); assert!(!is_compatible_direct_provider(&claude_official)); let mut openai_format = direct_provider("openai"); diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 815c19e94..f65ae413f 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -219,6 +219,17 @@ pub fn import_claude_desktop_providers_from_claude( Ok(imported) } +#[tauri::command] +pub fn ensure_claude_desktop_official_provider(state: State<'_, AppState>) -> Result { + state + .db + .ensure_official_seed_by_id( + crate::database::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, + AppType::ClaudeDesktop, + ) + .map_err(|e| e.to_string()) +} + fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool { let Some(env) = provider .settings_config diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a332f7dcf..62f134499 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1105,6 +1105,7 @@ pub fn run() { commands::get_claude_desktop_status, commands::get_claude_desktop_default_routes, commands::import_claude_desktop_providers_from_claude, + commands::ensure_claude_desktop_official_provider, commands::get_claude_config_status, commands::get_config_status, commands::get_claude_code_config_path, diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 3ee783e31..04f41a16f 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -30,6 +30,7 @@ interface AddProviderDialogProps { provider: Omit & { providerKey?: string; suggestedDefaults?: OpenClawSuggestedDefaults; + ensureClaudeDesktopOfficialSeed?: boolean; }, ) => Promise | void; } @@ -98,6 +99,7 @@ export function AddProviderDialog({ const providerData: Omit & { providerKey?: string; suggestedDefaults?: OpenClawSuggestedDefaults; + ensureClaudeDesktopOfficialSeed?: boolean; } = { name: values.name.trim(), notes: values.notes?.trim() || undefined, @@ -109,6 +111,16 @@ export function AddProviderDialog({ ...(values.meta ? { meta: values.meta } : {}), }; + if (appId === "claude-desktop" && values.presetId) { + const presetIndex = parseInt( + values.presetId.replace("claude-desktop-", ""), + ); + const preset = claudeDesktopProviderPresets[presetIndex]; + providerData.ensureClaudeDesktopOfficialSeed = + values.presetCategory === "official" && + preset?.category === "official"; + } + // OpenCode/OpenClaw: pass providerKey for ID generation if ( (appId === "opencode" || appId === "openclaw" || appId === "hermes") && diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index afc8827a0..583e9b794 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -75,6 +75,7 @@ export function useProviderActions( providerKey?: string; suggestedDefaults?: OpenClawSuggestedDefaults; addToLive?: boolean; + ensureClaudeDesktopOfficialSeed?: boolean; }, ) => { const enhanced = injectCodingPlanUsageScript(activeApp, provider); diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index 452d96522..cbdb6a55e 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -99,6 +99,10 @@ export const providersApi = { return await invoke("import_claude_desktop_providers_from_claude"); }, + async ensureClaudeDesktopOfficialProvider(): Promise { + return await invoke("ensure_claude_desktop_official_provider"); + }, + async getClaudeDesktopStatus(): Promise { return await invoke("get_claude_desktop_status"); }, diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 51d3058dd..b23797a7f 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -19,8 +19,26 @@ export const useAddProviderMutation = (appId: AppId) => { providerInput: Omit & { providerKey?: string; addToLive?: boolean; + ensureClaudeDesktopOfficialSeed?: boolean; }, ) => { + const { + providerKey: _providerKey, + addToLive, + ensureClaudeDesktopOfficialSeed, + ...rest + } = providerInput; + + if (appId === "claude-desktop" && ensureClaudeDesktopOfficialSeed) { + await providersApi.ensureClaudeDesktopOfficialProvider(); + const providers = await providersApi.getAll(appId); + const officialProvider = providers["claude-desktop-official"]; + if (!officialProvider) { + throw new Error("Claude Desktop official provider was not created"); + } + return officialProvider; + } + let id: string; if (appId === "opencode" || appId === "openclaw" || appId === "hermes") { @@ -40,8 +58,6 @@ export const useAddProviderMutation = (appId: AppId) => { id = generateUUID(); } - const { providerKey: _providerKey, addToLive, ...rest } = providerInput; - const newProvider: Provider = { ...rest, id, diff --git a/tests/hooks/useAddProviderMutation.test.tsx b/tests/hooks/useAddProviderMutation.test.tsx new file mode 100644 index 000000000..b581383b1 --- /dev/null +++ b/tests/hooks/useAddProviderMutation.test.tsx @@ -0,0 +1,136 @@ +import type { ReactNode } from "react"; +import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAddProviderMutation } from "@/lib/query/mutations"; +import type { Provider } from "@/types"; + +const apiMocks = vi.hoisted(() => ({ + add: vi.fn(), + ensureClaudeDesktopOfficialProvider: vi.fn(), + getAll: vi.fn(), + updateTrayMenu: vi.fn(), +})); + +const uuidMocks = vi.hoisted(() => ({ + generateUUID: vi.fn(), +})); + +vi.mock("@/lib/api", () => ({ + providersApi: { + add: (...args: unknown[]) => apiMocks.add(...args), + ensureClaudeDesktopOfficialProvider: (...args: unknown[]) => + apiMocks.ensureClaudeDesktopOfficialProvider(...args), + getAll: (...args: unknown[]) => apiMocks.getAll(...args), + updateTrayMenu: (...args: unknown[]) => apiMocks.updateTrayMenu(...args), + }, + sessionsApi: {}, + settingsApi: {}, +})); + +vi.mock("@/utils/uuid", () => ({ + generateUUID: () => uuidMocks.generateUUID(), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + return { wrapper }; +} + +beforeEach(() => { + apiMocks.add.mockReset().mockResolvedValue(true); + apiMocks.ensureClaudeDesktopOfficialProvider + .mockReset() + .mockResolvedValue(true); + apiMocks.getAll.mockReset().mockResolvedValue({}); + apiMocks.updateTrayMenu.mockReset().mockResolvedValue(true); + uuidMocks.generateUUID.mockReset().mockReturnValue("generated-uuid"); +}); + +describe("useAddProviderMutation", () => { + it("duplicates Claude Desktop official providers with a fresh id", async () => { + const { wrapper } = createWrapper(); + const { result } = renderHook( + () => useAddProviderMutation("claude-desktop"), + { wrapper }, + ); + + const duplicatedProvider = await act(async () => + result.current.mutateAsync({ + name: "Claude Desktop Official copy", + settingsConfig: { env: {} }, + category: "official", + }), + ); + + expect(apiMocks.ensureClaudeDesktopOfficialProvider).not.toHaveBeenCalled(); + expect(apiMocks.add).toHaveBeenCalledTimes(1); + expect(apiMocks.add).toHaveBeenCalledWith( + expect.objectContaining({ + id: "generated-uuid", + name: "Claude Desktop Official copy", + category: "official", + }), + "claude-desktop", + undefined, + ); + expect(duplicatedProvider.id).toBe("generated-uuid"); + expect(duplicatedProvider.id).not.toBe("claude-desktop-official"); + }); + + it("returns the persisted seed row for the Claude Desktop official preset", async () => { + const seedProvider: Provider = { + id: "claude-desktop-official", + name: "Claude Desktop Official", + settingsConfig: { env: {} }, + websiteUrl: "https://claude.ai/download", + category: "official", + icon: "anthropic", + iconColor: "#D4915D", + createdAt: 123, + }; + apiMocks.getAll.mockResolvedValueOnce({ + "claude-desktop-official": seedProvider, + }); + const { wrapper } = createWrapper(); + const { result } = renderHook( + () => useAddProviderMutation("claude-desktop"), + { wrapper }, + ); + + const persistedProvider = await act(async () => + result.current.mutateAsync({ + name: "Renamed by form", + settingsConfig: { env: { ignored: true } }, + websiteUrl: "https://example.invalid", + category: "official", + icon: "custom-icon", + ensureClaudeDesktopOfficialSeed: true, + }), + ); + + expect(apiMocks.ensureClaudeDesktopOfficialProvider).toHaveBeenCalledTimes( + 1, + ); + expect(apiMocks.getAll).toHaveBeenCalledWith("claude-desktop"); + expect(apiMocks.add).not.toHaveBeenCalled(); + expect(persistedProvider).toEqual(seedProvider); + }); +});