diff --git a/bun.lock b/bun.lock index 555cc8e9..58e888fe 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,7 @@ "strip-ansi": "^7.2.0", "supports-hyperlinks": "^4.4.0", "tree-kill": "^1.2.2", + "turndown": "^7.2.4", "type-fest": "^5.5.0", "undici": "^7.24.6", "usehooks-ts": "^3.1.1", @@ -280,6 +281,8 @@ "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.1.0.tgz", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], + "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "https://registry.npmmirror.com/@mixmark-io/domino/-/domino-2.2.0.tgz", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "https://registry.npmmirror.com/@opentelemetry/api/-/api-1.9.1.tgz", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], @@ -1238,6 +1241,8 @@ "tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "turndown": ["turndown@7.2.4", "https://registry.npmmirror.com/turndown/-/turndown-7.2.4.tgz", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ=="], + "type-fest": ["type-fest@5.5.0", "https://registry.npmmirror.com/type-fest/-/type-fest-5.5.0.tgz", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], "type-is": ["type-is@2.0.1", "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx new file mode 100644 index 00000000..2f7ad26b --- /dev/null +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { Settings } from '../pages/Settings' +import { useSettingsStore } from '../stores/settingsStore' + +vi.mock('../api/agents', () => ({ + agentsApi: { + list: vi.fn().mockResolvedValue({ activeAgents: [], allAgents: [] }), + }, +})) + +vi.mock('../stores/providerStore', () => ({ + useProviderStore: () => ({ + providers: [], + activeId: null, + isLoading: false, + fetchProviders: vi.fn(), + deleteProvider: vi.fn(), + activateProvider: vi.fn(), + activateOfficial: vi.fn(), + testProvider: vi.fn(), + createProvider: vi.fn(), + updateProvider: vi.fn(), + testConfig: vi.fn(), + }), +})) + +vi.mock('../pages/AdapterSettings', () => ({ + AdapterSettings: () =>
Adapter Settings Mock
, +})) + +vi.mock('../stores/agentStore', () => ({ + useAgentStore: () => ({ + activeAgents: [], + allAgents: [], + isLoading: false, + error: null, + selectedAgent: null, + fetchAgents: vi.fn(), + selectAgent: vi.fn(), + }), +})) + +vi.mock('../stores/skillStore', () => ({ + useSkillStore: () => ({ + skills: [], + selectedSkill: null, + isLoading: false, + isDetailLoading: false, + error: null, + fetchSkills: vi.fn(), + fetchSkillDetail: vi.fn(), + clearSelection: vi.fn(), + }), +})) + +vi.mock('../components/chat/CodeViewer', () => ({ + CodeViewer: ({ code }: { code: string }) =>
{code}
, +})) + +describe('Settings > General tab', () => { + beforeEach(() => { + useSettingsStore.setState({ + locale: 'en', + skipWebFetchPreflight: true, + setSkipWebFetchPreflight: vi.fn().mockImplementation(async (enabled: boolean) => { + useSettingsStore.setState({ skipWebFetchPreflight: enabled }) + }), + }) + }) + + it('shows WebFetch preflight toggle enabled by default', () => { + render() + + fireEvent.click(screen.getByText('General')) + + const toggle = screen.getByLabelText('Skip WebFetch domain preflight') + expect(toggle).toBeChecked() + }) + + it('lets the user disable WebFetch preflight skipping', () => { + render() + + fireEvent.click(screen.getByText('General')) + + const toggle = screen.getByLabelText('Skip WebFetch domain preflight') + fireEvent.click(toggle) + + expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false) + }) +}) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index c7d877d7..017cdc84 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -302,6 +302,10 @@ export const en = { 'settings.general.effort.medium': 'Medium', 'settings.general.effort.high': 'High', 'settings.general.effort.max': 'Max', + 'settings.general.webFetchPreflightTitle': 'WebFetch Preflight', + 'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.', + 'settings.general.webFetchPreflightEnabled': 'Skip WebFetch domain preflight', + 'settings.general.webFetchPreflightHint': 'Turn this off only if you explicitly want to restore the upstream safety preflight before each WebFetch request.', // ─── Empty Session ────────────────────────────────────── 'empty.title': 'New session', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index f22512e2..62de007d 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -304,6 +304,10 @@ export const zh: Record = { 'settings.general.effort.medium': '中', 'settings.general.effort.high': '高', 'settings.general.effort.max': '最大', + 'settings.general.webFetchPreflightTitle': 'WebFetch 预检', + 'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。', + 'settings.general.webFetchPreflightEnabled': '跳过 WebFetch 域名预检', + 'settings.general.webFetchPreflightHint': '只有在你明确需要恢复上游默认安全预检时,才建议关闭这个选项。', // ─── Empty Session ────────────────────────────────────── 'empty.title': '新建会话', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index ede5cd5b..972459c2 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -311,6 +311,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) const needsProxy = apiFormat !== 'anthropic' const merged = { ...settings, + skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true, env: { ...((settings.env as Record) || {}), ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl, @@ -646,7 +647,16 @@ function PermissionSettings() { // ─── General Settings ────────────────────────────────────── function GeneralSettings() { - const { effortLevel, setEffort, locale, setLocale, theme, setTheme } = useSettingsStore() + const { + effortLevel, + setEffort, + locale, + setLocale, + theme, + setTheme, + skipWebFetchPreflight, + setSkipWebFetchPreflight, + } = useSettingsStore() const t = useTranslation() const EFFORT_LABELS: Record = { @@ -724,6 +734,28 @@ function GeneralSettings() { ))} + +
+

{t('settings.general.webFetchPreflightTitle')}

+

{t('settings.general.webFetchPreflightDescription')}

+ +
) } diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 7d1f4419..a08d0fdb 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -23,6 +23,7 @@ type SettingsStore = { activeProviderName: string | null locale: Locale theme: ThemeMode + skipWebFetchPreflight: boolean isLoading: boolean error: string | null @@ -32,6 +33,7 @@ type SettingsStore = { setEffort: (level: EffortLevel) => Promise setLocale: (locale: Locale) => void setTheme: (theme: ThemeMode) => Promise + setSkipWebFetchPreflight: (enabled: boolean) => Promise } export const useSettingsStore = create((set, get) => ({ @@ -42,6 +44,7 @@ export const useSettingsStore = create((set, get) => ({ activeProviderName: null, locale: getStoredLocale(), theme: useUIStore.getState().theme, + skipWebFetchPreflight: true, isLoading: false, error: null, @@ -64,6 +67,7 @@ export const useSettingsStore = create((set, get) => ({ currentModel: model, effortLevel: level, theme, + skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false, isLoading: false, error: null, }) @@ -117,4 +121,14 @@ export const useSettingsStore = create((set, get) => ({ useUIStore.getState().setTheme(prev) } }, + + setSkipWebFetchPreflight: async (enabled) => { + const prev = get().skipWebFetchPreflight + set({ skipWebFetchPreflight: enabled }) + try { + await settingsApi.updateUser({ skipWebFetchPreflight: enabled }) + } catch { + set({ skipWebFetchPreflight: prev }) + } + }, })) diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 68918d97..9b246eea 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -18,5 +18,6 @@ export type UserSettings = { effort?: EffortLevel permissionMode?: PermissionMode theme?: ThemeMode + skipWebFetchPreflight?: boolean [key: string]: unknown } diff --git a/package.json b/package.json index 8e250226..575fff06 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "strip-ansi": "^7.2.0", "supports-hyperlinks": "^4.4.0", "tree-kill": "^1.2.2", + "turndown": "^7.2.4", "type-fest": "^5.5.0", "undici": "^7.24.6", "usehooks-ts": "^3.1.1", diff --git a/src/tools/WebFetchTool/utils.test.ts b/src/tools/WebFetchTool/utils.test.ts new file mode 100644 index 00000000..3891c0a9 --- /dev/null +++ b/src/tools/WebFetchTool/utils.test.ts @@ -0,0 +1,42 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { shouldSkipWebFetchPreflight } from './utils.js' + +describe('shouldSkipWebFetchPreflight', () => { + const originalDesktopServerUrl = process.env.CC_HAHA_DESKTOP_SERVER_URL + + beforeEach(() => { + delete process.env.CC_HAHA_DESKTOP_SERVER_URL + }) + + afterEach(() => { + if (originalDesktopServerUrl === undefined) { + delete process.env.CC_HAHA_DESKTOP_SERVER_URL + } else { + process.env.CC_HAHA_DESKTOP_SERVER_URL = originalDesktopServerUrl + } + }) + + test('respects explicit true from settings', () => { + expect( + shouldSkipWebFetchPreflight({ skipWebFetchPreflight: true }), + ).toBe(true) + }) + + test('respects explicit false from settings even on desktop', () => { + process.env.CC_HAHA_DESKTOP_SERVER_URL = 'http://127.0.0.1:3456' + + expect( + shouldSkipWebFetchPreflight({ skipWebFetchPreflight: false }), + ).toBe(false) + }) + + test('defaults to enabled for desktop sessions', () => { + process.env.CC_HAHA_DESKTOP_SERVER_URL = 'http://127.0.0.1:3456' + + expect(shouldSkipWebFetchPreflight({})).toBe(true) + }) + + test('defaults to disabled outside desktop sessions', () => { + expect(shouldSkipWebFetchPreflight({})).toBe(false) + }) +}) diff --git a/src/tools/WebFetchTool/utils.ts b/src/tools/WebFetchTool/utils.ts index 6d55f70b..d1f5dfd9 100644 --- a/src/tools/WebFetchTool/utils.ts +++ b/src/tools/WebFetchTool/utils.ts @@ -77,6 +77,19 @@ const DOMAIN_CHECK_CACHE = new LRUCache({ ttl: 5 * 60 * 1000, // 5 minutes — shorter than URL_CACHE TTL }) +export function shouldSkipWebFetchPreflight( + settings: { skipWebFetchPreflight?: boolean } = getSettings_DEPRECATED(), +): boolean { + if (settings.skipWebFetchPreflight !== undefined) { + return settings.skipWebFetchPreflight + } + + // Desktop sessions often route through third-party providers or constrained + // corporate networks where Anthropic's domain preflight fails despite the + // actual target URL being reachable through the configured provider path. + return Boolean(process.env.CC_HAHA_DESKTOP_SERVER_URL) +} + export function clearWebFetchCache(): void { URL_CACHE.clear() DOMAIN_CHECK_CACHE.clear() @@ -384,7 +397,7 @@ export async function getURLMarkdownContent( // This is for enterprise customers with restrictive security policies // that prevent outbound connections to claude.ai const settings = getSettings_DEPRECATED() - if (!settings.skipWebFetchPreflight) { + if (!shouldSkipWebFetchPreflight(settings)) { const checkResult = await checkDomainBlocklist(hostname) switch (checkResult.status) { case 'allowed':