mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
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:
parent
df8db2c1ff
commit
febd842557
5
bun.lock
5
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=="],
|
||||
|
||||
93
desktop/src/__tests__/generalSettings.test.tsx
Normal file
93
desktop/src/__tests__/generalSettings.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
|
||||
@ -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': '新建会话',
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@ -18,5 +18,6 @@ export type UserSettings = {
|
||||
effort?: EffortLevel
|
||||
permissionMode?: PermissionMode
|
||||
theme?: ThemeMode
|
||||
skipWebFetchPreflight?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
42
src/tools/WebFetchTool/utils.test.ts
Normal file
42
src/tools/WebFetchTool/utils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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':
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user