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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-20 20:53:03 +08:00
parent df8db2c1ff
commit febd842557
10 changed files with 211 additions and 2 deletions

View File

@ -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=="],

View File

@ -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: () => <div>Adapter Settings Mock</div>,
}))
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 }) => <pre data-testid="code-viewer">{code}</pre>,
}))
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(<Settings />)
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(<Settings />)
fireEvent.click(screen.getByText('General'))
const toggle = screen.getByLabelText('Skip WebFetch domain preflight')
fireEvent.click(toggle)
expect(useSettingsStore.getState().setSkipWebFetchPreflight).toHaveBeenCalledWith(false)
})
})

View File

@ -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',

View File

@ -304,6 +304,10 @@ export const zh: Record<TranslationKey, string> = {
'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': '新建会话',

View File

@ -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<string, string>) || {}),
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<EffortLevel, string> = {
@ -724,6 +734,28 @@ function GeneralSettings() {
</button>
))}
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.webFetchPreflightTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.webFetchPreflightDescription')}</p>
<label className="flex items-start gap-3 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 cursor-pointer hover:border-[var(--color-border-focus)] transition-colors">
<input
type="checkbox"
aria-label={t('settings.general.webFetchPreflightEnabled')}
checked={skipWebFetchPreflight}
onChange={(e) => void setSkipWebFetchPreflight(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-[var(--color-border)] text-[var(--color-brand)] focus:ring-[var(--color-brand)]"
/>
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.general.webFetchPreflightEnabled')}
</div>
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
{t('settings.general.webFetchPreflightHint')}
</div>
</div>
</label>
</div>
</div>
)
}

View File

@ -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<void>
setLocale: (locale: Locale) => void
setTheme: (theme: ThemeMode) => Promise<void>
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
@ -42,6 +44,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
activeProviderName: null,
locale: getStoredLocale(),
theme: useUIStore.getState().theme,
skipWebFetchPreflight: true,
isLoading: false,
error: null,
@ -64,6 +67,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
currentModel: model,
effortLevel: level,
theme,
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
isLoading: false,
error: null,
})
@ -117,4 +121,14 @@ export const useSettingsStore = create<SettingsStore>((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 })
}
},
}))

View File

@ -18,5 +18,6 @@ export type UserSettings = {
effort?: EffortLevel
permissionMode?: PermissionMode
theme?: ThemeMode
skipWebFetchPreflight?: boolean
[key: string]: unknown
}

View File

@ -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",

View File

@ -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)
})
})

View File

@ -77,6 +77,19 @@ const DOMAIN_CHECK_CACHE = new LRUCache<string, true>({
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':