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':