From febd8425572cabeecd1871b3b13e2f20f42ce1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 20 Apr 2026 20:53:03 +0800 Subject: [PATCH] Keep desktop WebFetch usable on third-party providers Desktop sessions were failing before the actual fetch because Anthropic's domain preflight can be unreachable on restricted networks, and the next runtime path was missing turndown for HTML-to-Markdown conversion. This change defaults desktop sessions to skip the preflight unless the user explicitly overrides it, exposes that behavior as a desktop General setting, seeds new settings JSON with the desktop-safe default, and adds regression coverage for both the runtime default and the UI toggle. It also adds the missing turndown dependency so successful fetches can continue through HTML reduction instead of failing at module resolution. Constraint: Desktop must keep an escape hatch for users who want upstream preflight restored explicitly Rejected: Force skipWebFetchPreflight globally for every session | would silently change CLI and non-desktop behavior Rejected: UI-only toggle without runtime default | existing desktop users would still fail until they manually opened settings Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep desktop-specific WebFetch behavior scoped to desktop session detection and explicit user settings; do not broaden it to general CLI flows without separate validation Tested: bun test src/tools/WebFetchTool/utils.test.ts; cd desktop && bun run lint; cd desktop && bunx vitest run src/__tests__/generalSettings.test.tsx; runtime import verification for turndown via node Not-tested: End-to-end desktop packaging smoke test against a freshly built DMG/app bundle --- bun.lock | 5 + .../src/__tests__/generalSettings.test.tsx | 93 +++++++++++++++++++ desktop/src/i18n/locales/en.ts | 4 + desktop/src/i18n/locales/zh.ts | 4 + desktop/src/pages/Settings.tsx | 34 ++++++- desktop/src/stores/settingsStore.ts | 14 +++ desktop/src/types/settings.ts | 1 + package.json | 1 + src/tools/WebFetchTool/utils.test.ts | 42 +++++++++ src/tools/WebFetchTool/utils.ts | 15 ++- 10 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 desktop/src/__tests__/generalSettings.test.tsx create mode 100644 src/tools/WebFetchTool/utils.test.ts 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':