diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 233e4283..cc918324 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -242,6 +242,7 @@ describe('Settings > General tab', () => { skipWebFetchPreflight: true, desktopNotificationsEnabled: true, traceCapture: { enabled: true, storageDir: '/Users/test/.claude/cc-haha/traces' }, + nonEssentialTrafficDisabled: true, chatSendBehavior: 'enter', responseLanguage: '', uiZoom: 1, @@ -303,6 +304,9 @@ describe('Settings > General tab', () => { const current = useSettingsStore.getState().traceCapture useSettingsStore.setState({ traceCapture: { ...current, enabled } }) }), + setNonEssentialTrafficDisabled: vi.fn().mockImplementation(async (disabled: boolean) => { + useSettingsStore.setState({ nonEssentialTrafficDisabled: disabled }) + }), setChatSendBehavior: vi.fn().mockImplementation(async (chatSendBehavior: ChatSendBehavior) => { useSettingsStore.setState({ chatSendBehavior }) }), @@ -983,6 +987,7 @@ describe('Settings > General tab', () => { 'Enable thinking mode', 'Enable Auto-dream', 'Collect agent traces', + 'Block non-essential external traffic', 'Enable system notifications', 'Skip WebFetch domain preflight', ]) { @@ -1011,6 +1016,21 @@ describe('Settings > General tab', () => { expect(screen.getByText('Message Sending')).toBeInTheDocument() }) + it('lets the user toggle blocking non-essential external traffic, on by default', async () => { + render() + + fireEvent.click(screen.getByText('General')) + const toggle = screen.getByLabelText('Block non-essential external traffic') + expect(toggle).toBeChecked() + + await act(async () => { + fireEvent.click(toggle) + }) + + expect(useSettingsStore.getState().setNonEssentialTrafficDisabled).toHaveBeenCalledWith(false) + expect(screen.getByLabelText('Block non-essential external traffic')).not.toBeChecked() + }) + it('uses the shared dropdown for response language', () => { render() diff --git a/desktop/src/api/privacy.ts b/desktop/src/api/privacy.ts new file mode 100644 index 00000000..c222bb5a --- /dev/null +++ b/desktop/src/api/privacy.ts @@ -0,0 +1,18 @@ +import { api } from './client' + +export type NonEssentialTrafficSettings = { + /** true = 禁用非必要外部流量(默认开启) */ + disabled: boolean +} + +export const privacyApi = { + getNonEssentialTrafficSettings() { + return api.get('/api/privacy/non-essential-traffic') + }, + + updateNonEssentialTrafficSettings(disabled: boolean) { + return api.put('/api/privacy/non-essential-traffic', { + disabled, + }) + }, +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index cbff188d..b6de9aec 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1476,6 +1476,11 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'settings.general.traceEnabled': 'Collect agent traces', 'settings.general.traceHintOn': 'New sessions write compact requests, responses, and status events to the local cc-haha traces directory.', 'settings.general.traceHintOff': 'No new traces will be written. Existing records remain available in the trace list.', + 'settings.general.nonEssentialTrafficTitle': 'Block non-essential external traffic', + 'settings.general.nonEssentialTrafficDescription': 'Stop all non-essential outbound requests (telemetry, update checks, official registry, marketplaces). Model API calls to providers you configure are never affected.', + 'settings.general.nonEssentialTrafficEnabled': 'Block non-essential external traffic', + 'settings.general.nonEssentialTrafficHintOn': 'Telemetry, update checks, and marketplace prefetches are disabled. Login and model requests keep working. Sessions already open continue with their previous settings until restarted. Turn this off if you use an Anthropic account — some account features (usage, fast mode) depend on it.', + 'settings.general.nonEssentialTrafficHintOff': 'Non-essential external traffic is allowed. Turn this on to prevent any outbound traffic beyond your configured providers.', 'settings.general.chatSendBehaviorTitle': 'Message Sending', 'settings.general.chatSendBehaviorDescription': 'Choose how the desktop chat composer submits messages.', 'settings.general.chatSendBehaviorEnter': 'Enter sends', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index a87c92ac..91f02106 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1478,6 +1478,11 @@ export const jp: Record = { 'settings.general.traceEnabled': 'Agent Trace を収集', 'settings.general.traceHintOn': '新しいセッションは、簡潔な要求、応答、状態イベントをローカルの cc-haha traces ディレクトリに書き込みます。', 'settings.general.traceHintOff': '新しい Trace は書き込まれません。既存の記録は Trace 一覧で引き続き確認できます。', + 'settings.general.nonEssentialTrafficTitle': '不要な外部トラフィックを無効化', + 'settings.general.nonEssentialTrafficDescription': '不要な送信リクエスト(テレメトリ、更新確認、公式レジストリ、プラグイン/Skills マーケットなど)をすべてブロックします。設定済みプロバイダーへのモデル API 呼び出しには影響しません。', + 'settings.general.nonEssentialTrafficEnabled': '不要な外部トラフィックを無効化', + 'settings.general.nonEssentialTrafficHintOn': 'テレメトリ、更新確認、マーケットのプリフェッチは無効です。ログインとモデルリクエストは引き続き機能します。起動済みのセッションは切り替え前の設定を引き継ぎ、再起動後に反映されます。Anthropic 公式アカウントを使用している場合は、このスイッチをオフにすることをお勧めします(一部のアカウント機能はこのトラフィックに依存します)。', + 'settings.general.nonEssentialTrafficHintOff': '不要な外部トラフィックを許可します。有効にすると、設定済みプロバイダー以外への送信リクエストをすべてブロックできます。', 'settings.general.chatSendBehaviorTitle': 'メッセージの送信', 'settings.general.chatSendBehaviorDescription': 'デスクトップのチャット入力欄でメッセージを送信する方法を選択します。', 'settings.general.chatSendBehaviorEnter': 'Enter で送信', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index b9ad7c38..43c4b1b4 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1478,6 +1478,11 @@ export const kr: Record = { 'settings.general.traceEnabled': 'Agent Trace 수집', 'settings.general.traceHintOn': '새 세션은 간결한 요청, 응답, 상태 이벤트를 로컬 cc-haha traces 디렉터리에 기록합니다.', 'settings.general.traceHintOff': '새 Trace는 기록되지 않습니다. 기존 기록은 Trace 목록에서 계속 볼 수 있습니다.', + 'settings.general.nonEssentialTrafficTitle': '불필요한 외부 트래픽 차단', + 'settings.general.nonEssentialTrafficDescription': '불필요한 모든 발신 요청(원격 측정, 업데이트 확인, 공식 레지스트리, 플러그인/Skills 마켓 등)을 차단합니다. 설정한 모델 API 호출에는 영향을 주지 않습니다.', + 'settings.general.nonEssentialTrafficEnabled': '불필요한 외부 트래픽 차단', + 'settings.general.nonEssentialTrafficHintOn': '원격 측정, 업데이트 확인, 마켓 프리페치가 비활성화됩니다. 로그인과 모델 요청은 계속 작동합니다. 이미 열린 세션은 전환 전 설정을 유지하며 재시작 후 적용됩니다. Anthropic 공식 계정을 사용 중이라면 이 스위치를 끄는 것이 좋습니다(일부 계정 기능이 이 트래픽에 의존합니다).', + 'settings.general.nonEssentialTrafficHintOff': '불필요한 외부 트래픽을 허용합니다. 켜면 설정한 프로바이더 외에는 발신 요청이 발생하지 않습니다.', 'settings.general.chatSendBehaviorTitle': '메시지 전송', 'settings.general.chatSendBehaviorDescription': '데스크톱 채팅 입력창에서 메시지를 전송하는 방식을 선택합니다.', 'settings.general.chatSendBehaviorEnter': 'Enter로 전송', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 5a3506a1..7dffac65 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1477,6 +1477,11 @@ export const zh: Record = { 'settings.general.traceEnabled': '收集 Agent Trace', 'settings.general.traceHintOn': '新會話會把精簡請求、回應、狀態事件寫入本機 cc-haha traces 目錄。', 'settings.general.traceHintOff': '關閉後不會寫入新的 Trace;已有記錄仍可在 Trace 列表裡查看。', + 'settings.general.nonEssentialTrafficTitle': '禁用非必要外部流量', + 'settings.general.nonEssentialTrafficDescription': '屏蔽所有非必要出站請求(遙測、更新檢查、官方註冊表、插件/Skills 市場等)。你配置的模型 API 調用完全不受影響。', + 'settings.general.nonEssentialTrafficEnabled': '禁用非必要外部流量', + 'settings.general.nonEssentialTrafficHintOn': '遙測、更新檢查、市場預取等均已關閉,登入和模型請求不受影響。已開啟的會話會沿用切換前的設定,重啟會話後生效。如果你使用 Anthropic 官方帳號,建議關閉此開關——部分帳號功能(用量、Fast 模式)依賴這些流量。', + 'settings.general.nonEssentialTrafficHintOff': '允許非必要外部流量。開啟後可確保除你配置的 Provider 外不再產生任何出站請求。', 'settings.general.chatSendBehaviorTitle': '訊息傳送方式', 'settings.general.chatSendBehaviorDescription': '選擇桌面端對話輸入框如何傳送訊息。', 'settings.general.chatSendBehaviorEnter': 'Enter 傳送', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 91507837..695a14f3 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1477,6 +1477,11 @@ export const zh: Record = { 'settings.general.traceEnabled': '收集 Agent Trace', 'settings.general.traceHintOn': '新会话会把精简请求、响应、状态事件写入本机 cc-haha traces 目录。', 'settings.general.traceHintOff': '关闭后不会写入新的 Trace;已有记录仍可在 Trace 列表里查看。', + 'settings.general.nonEssentialTrafficTitle': '禁用非必要外部流量', + 'settings.general.nonEssentialTrafficDescription': '屏蔽所有非必要出站请求(遥测、更新检查、官方注册表、插件/Skills 市场等)。你配置的模型 API 调用完全不受影响。', + 'settings.general.nonEssentialTrafficEnabled': '禁用非必要外部流量', + 'settings.general.nonEssentialTrafficHintOn': '遥测、更新检查、市场预取等均已关闭,登录和模型请求不受影响。已开启的会话会沿用切换前的设置,重启会话后生效。如果你使用 Anthropic 官方账号,建议关闭此开关——部分账号功能(用量、Fast 模式)依赖这些流量。', + 'settings.general.nonEssentialTrafficHintOff': '允许非必要外部流量。开启后可确保除你配置的 Provider 外不再产生任何出站请求。', 'settings.general.chatSendBehaviorTitle': '消息发送方式', 'settings.general.chatSendBehaviorDescription': '选择桌面端对话输入框如何发送消息。', 'settings.general.chatSendBehaviorEnter': 'Enter 发送', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 98147207..2754f98f 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -2362,6 +2362,8 @@ export function GeneralSettings() { setNetwork, traceCapture, setTraceCaptureEnabled, + nonEssentialTrafficDisabled, + setNonEssentialTrafficDisabled, responseLanguage, setResponseLanguage, appMode, @@ -3171,6 +3173,31 @@ export function GeneralSettings() { +
+

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

+

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

+ +
+

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

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

diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 2f61d47d..14d2330b 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -132,6 +132,14 @@ describe('settingsStore locale defaults', () => { expect(window.localStorage.getItem('cc-haha-locale')).toBe('kr') }) + it('defaults to blocking non-essential traffic (disabled=true)', async () => { + mockSystemLanguages(['en-US']) + + const { useSettingsStore } = await import('./settingsStore') + + expect(useSettingsStore.getState().nonEssentialTrafficDisabled).toBe(true) + }) + it('applies a locale event from another desktop window', async () => { mockSystemLanguages(['en-US']) let emitLocale: ((locale: Locale) => void) | undefined diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index fb2e8616..4dfea0d6 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -4,6 +4,7 @@ import { settingsApi } from '../api/settings' import { modelsApi } from '../api/models' import { h5AccessApi } from '../api/h5Access' import { tracesApi } from '../api/traces' +import { privacyApi } from '../api/privacy' import { type AppMode, type AppModeConfig, @@ -77,6 +78,8 @@ type SettingsStore = { updateProxy: UpdateProxySettings network: NetworkSettings traceCapture: TraceCaptureSettings + /** true = 禁用非必要外部流量(遥测/更新检查/官方注册表等,默认开启) */ + nonEssentialTrafficDisabled: boolean h5Access: H5AccessSettings h5AccessDiagnostics: H5AccessDiagnostics | null h5AccessError: string | null @@ -108,6 +111,7 @@ type SettingsStore = { setUpdateProxy: (settings: UpdateProxySettings) => Promise setNetwork: (settings: NetworkSettings) => Promise setTraceCaptureEnabled: (enabled: boolean) => Promise + setNonEssentialTrafficDisabled: (disabled: boolean) => Promise enableH5Access: () => Promise disableH5Access: () => Promise regenerateH5AccessToken: () => Promise @@ -200,6 +204,7 @@ export const useSettingsStore = create((set, get) => ({ updateProxy: DEFAULT_UPDATE_PROXY_SETTINGS, network: DEFAULT_NETWORK_SETTINGS, traceCapture: DEFAULT_TRACE_CAPTURE_SETTINGS, + nonEssentialTrafficDisabled: true, h5Access: DEFAULT_H5_ACCESS_SETTINGS, h5AccessDiagnostics: null, h5AccessError: null, @@ -225,7 +230,16 @@ export const useSettingsStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { const previousH5Access = get().h5Access - const [{ mode }, modelsRes, { model }, { level }, userSettings, h5AccessResult, traceCapture] = await Promise.all([ + const [ + { mode }, + modelsRes, + { model }, + { level }, + userSettings, + h5AccessResult, + traceCapture, + nonEssentialTrafficDisabled, + ] = await Promise.all([ settingsApi.getPermissionMode(), modelsApi.list(), modelsApi.getCurrent(), @@ -233,6 +247,7 @@ export const useSettingsStore = create((set, get) => ({ settingsApi.getUser(), loadH5AccessSettings(previousH5Access), loadTraceCaptureSettings(), + loadNonEssentialTrafficSettings(), ]) const desktopTerminal = normalizeDesktopTerminalSettings(userSettings.desktopTerminal) lastPersistedDesktopTerminal = desktopTerminal @@ -258,6 +273,7 @@ export const useSettingsStore = create((set, get) => ({ updateProxy: normalizeUpdateProxySettings(userSettings.updateProxy), network: normalizeNetworkSettings(userSettings.network), traceCapture, + nonEssentialTrafficDisabled, h5Access: h5AccessResult.settings, h5AccessDiagnostics: h5AccessResult.diagnostics, h5AccessError: h5AccessResult.error, @@ -517,6 +533,18 @@ export const useSettingsStore = create((set, get) => ({ } }, + setNonEssentialTrafficDisabled: async (disabled) => { + const prev = get().nonEssentialTrafficDisabled + set({ nonEssentialTrafficDisabled: disabled }) + try { + const next = await privacyApi.updateNonEssentialTrafficSettings(disabled) + set({ nonEssentialTrafficDisabled: next.disabled }) + } catch (error) { + set({ nonEssentialTrafficDisabled: prev }) + throw error + } + }, + enableH5Access: async () => { set({ h5AccessError: null }) try { @@ -748,6 +776,15 @@ async function loadTraceCaptureSettings(): Promise { } } +async function loadNonEssentialTrafficSettings(): Promise { + try { + const { disabled } = await privacyApi.getNonEssentialTrafficSettings() + return disabled + } catch { + return true + } +} + async function refreshH5DiagnosticsSilent( set: (partial: Partial) => void, ): Promise { diff --git a/desktop/src/stores/updateStore.test.ts b/desktop/src/stores/updateStore.test.ts index f1f47f09..54c9ff01 100644 --- a/desktop/src/stores/updateStore.test.ts +++ b/desktop/src/stores/updateStore.test.ts @@ -47,6 +47,45 @@ describe('updateStore', () => { Reflect.deleteProperty(window, '__TAURI__') }) + it('skips the startup update check when non-essential traffic is blocked', async () => { + vi.useFakeTimers() + try { + vi.resetModules() + const { useSettingsStore } = await import('./settingsStore') + const { useUpdateStore } = await import('./updateStore') + useSettingsStore.setState({ nonEssentialTrafficDisabled: true }) + + const promise = useUpdateStore.getState().initialize() + await vi.advanceTimersByTimeAsync(6000) + await promise + + expect(check).not.toHaveBeenCalled() + expect(useUpdateStore.getState().status).toBe('idle') + } finally { + vi.useRealTimers() + } + }) + + it('runs the startup update check when non-essential traffic is allowed', async () => { + vi.useFakeTimers() + try { + vi.resetModules() + const { useSettingsStore } = await import('./settingsStore') + const { useUpdateStore } = await import('./updateStore') + useSettingsStore.setState({ nonEssentialTrafficDisabled: false }) + check.mockResolvedValue(null) + + const promise = useUpdateStore.getState().initialize() + await vi.advanceTimersByTimeAsync(6000) + await promise + + expect(check).toHaveBeenCalledTimes(1) + expect(useUpdateStore.getState().status).toBe('up-to-date') + } finally { + vi.useRealTimers() + } + }) + it('stores available update metadata after a successful check', async () => { const download = vi.fn(async (onEvent?: (event: unknown) => void) => { onEvent?.({ event: 'Started', data: { contentLength: 200 } }) diff --git a/desktop/src/stores/updateStore.ts b/desktop/src/stores/updateStore.ts index 58e767fd..439ddedf 100644 --- a/desktop/src/stores/updateStore.ts +++ b/desktop/src/stores/updateStore.ts @@ -198,6 +198,9 @@ export const useUpdateStore = create((set, get) => ({ if (!startupCheckPromise) { startupCheckPromise = (async () => { await new Promise((resolve) => setTimeout(resolve, 5000)) + // 禁用非必要外部流量时跳过自动更新检查。延迟到此时才读设置, + // 保证 settingsStore.fetchAll 已完成、拿到的是真实开关值。 + if (useSettingsStore.getState().nonEssentialTrafficDisabled) return await get().checkForUpdates({ silent: true }) })().finally(() => { startupCheckPromise = null diff --git a/src/server/__tests__/market-api.test.ts b/src/server/__tests__/market-api.test.ts index b450af4b..8edc67db 100644 --- a/src/server/__tests__/market-api.test.ts +++ b/src/server/__tests__/market-api.test.ts @@ -10,6 +10,7 @@ const FIXTURES = path.join(import.meta.dir, 'fixtures', 'market') let tmpHome: string let originalClaudeConfigDir: string | undefined +let originalNonEssentialTrafficEnv: string | undefined const originalFetch = globalThis.fetch async function fixture(name: string): Promise { @@ -35,12 +36,21 @@ beforeEach(async () => { tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'market-api-test-')) originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude') + // These tests stub upstreams and must not inherit the developer shell's + // essential-traffic env (which would block the market providers entirely). + originalNonEssentialTrafficEnv = process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC }) afterEach(async () => { globalThis.fetch = originalFetch if (originalClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR else process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + if (originalNonEssentialTrafficEnv === undefined) { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + } else { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = originalNonEssentialTrafficEnv + } delete process.env.HAHA_MARKET_DISABLE_PROVIDERS await fs.rm(tmpHome, { recursive: true, force: true }) }) diff --git a/src/server/__tests__/market-install.test.ts b/src/server/__tests__/market-install.test.ts index 327c912a..5456a911 100644 --- a/src/server/__tests__/market-install.test.ts +++ b/src/server/__tests__/market-install.test.ts @@ -14,6 +14,7 @@ import { MARKET_LIMITS } from '../services/market/types.js' let tmpHome: string let originalClaudeConfigDir: string | undefined +let originalNonEssentialTrafficEnv: string | undefined const originalFetch = globalThis.fetch const SKILL_MD = '---\nname: demo\n---\n# Demo skill' @@ -73,12 +74,21 @@ beforeEach(async () => { tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'market-install-test-')) originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude') + // These tests stub upstreams and must not inherit the developer shell's + // essential-traffic env (which would block the market providers entirely). + originalNonEssentialTrafficEnv = process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC }) afterEach(async () => { globalThis.fetch = originalFetch if (originalClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR else process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + if (originalNonEssentialTrafficEnv === undefined) { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + } else { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = originalNonEssentialTrafficEnv + } await fs.rm(tmpHome, { recursive: true, force: true }) }) diff --git a/src/server/__tests__/market-providers.test.ts b/src/server/__tests__/market-providers.test.ts index da2567dd..cacb677e 100644 --- a/src/server/__tests__/market-providers.test.ts +++ b/src/server/__tests__/market-providers.test.ts @@ -285,3 +285,29 @@ describe('skillhubProvider', () => { expect(page.items[1]!.securityStatus).toBe('unknown') }) }) + +describe('non-essential traffic gate', () => { + const NON_ESSENTIAL_TRAFFIC_ENV = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' + let originalEnv: string | undefined + + beforeEach(() => { + originalEnv = process.env[NON_ESSENTIAL_TRAFFIC_ENV] + process.env[NON_ESSENTIAL_TRAFFIC_ENV] = '1' + }) + + afterEach(() => { + if (originalEnv === undefined) delete process.env[NON_ESSENTIAL_TRAFFIC_ENV] + else process.env[NON_ESSENTIAL_TRAFFIC_ENV] = originalEnv + }) + + it('blocks upstream requests and records a source failure when essential-traffic is on', async () => { + stubFetch(() => { + throw new Error('must not be reached') + }) + + await expect(clawhubProvider.list({ limit: 3 })).rejects.toThrow( + /disabled by non-essential traffic setting/, + ) + expect(requestedUrls).toEqual([]) + }) +}) diff --git a/src/server/__tests__/market-service.test.ts b/src/server/__tests__/market-service.test.ts index f49ed55c..8636694e 100644 --- a/src/server/__tests__/market-service.test.ts +++ b/src/server/__tests__/market-service.test.ts @@ -18,6 +18,7 @@ const FIXTURES = path.join(import.meta.dir, 'fixtures', 'market') let tmpHome: string let originalClaudeConfigDir: string | undefined +let originalNonEssentialTrafficEnv: string | undefined let requested: string[] = [] const originalFetch = globalThis.fetch @@ -70,6 +71,10 @@ beforeEach(async () => { tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'market-service-test-')) originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude') + // These tests stub upstreams and must not inherit the developer shell's + // essential-traffic env (which would block the market providers entirely). + originalNonEssentialTrafficEnv = process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC delete process.env.HAHA_MARKET_DISABLE_PROVIDERS }) @@ -77,6 +82,11 @@ afterEach(async () => { globalThis.fetch = originalFetch if (originalClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR else process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + if (originalNonEssentialTrafficEnv === undefined) { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + } else { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = originalNonEssentialTrafficEnv + } delete process.env.HAHA_MARKET_DISABLE_PROVIDERS await fs.rm(tmpHome, { recursive: true, force: true }) }) diff --git a/src/server/__tests__/non-essential-traffic.test.ts b/src/server/__tests__/non-essential-traffic.test.ts new file mode 100644 index 00000000..b0a4a618 --- /dev/null +++ b/src/server/__tests__/non-essential-traffic.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { promises as fs } from 'fs' +import * as os from 'os' +import * as path from 'path' +import { handleApiRequest } from '../router.js' +import { + applyNonEssentialTrafficSetting, + NON_ESSENTIAL_TRAFFIC_ENV, + readNonEssentialTrafficDisabled, + readNonEssentialTrafficDisabledSync, + syncNonEssentialTrafficEnv, + updateNonEssentialTrafficDisabled, +} from '../../services/api/nonEssentialTraffic.js' +import { getGlobalConfig } from '../../utils/config.js' + +let tmpDir: string +let originalConfigDir: string | undefined + +function settingsPath(): string { + return path.join(tmpDir, 'cc-haha', 'settings.json') +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'non-essential-traffic-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir + delete process.env[NON_ESSENTIAL_TRAFFIC_ENV] +}) + +afterEach(async () => { + if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalConfigDir + delete process.env[NON_ESSENTIAL_TRAFFIC_ENV] + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +describe('non-essential traffic setting', () => { + test('defaults to disabled (true) when no settings file exists', () => { + expect(readNonEssentialTrafficDisabledSync()).toBe(true) + }) + + test('reads the disabled flag from cc-haha/settings.json', async () => { + await fs.mkdir(path.dirname(settingsPath()), { recursive: true }) + await fs.writeFile(settingsPath(), JSON.stringify({ nonEssentialTraffic: { disabled: false } })) + expect(readNonEssentialTrafficDisabledSync()).toBe(false) + expect(await readNonEssentialTrafficDisabled()).toBe(false) + }) + + test('missing field falls back to the default (disabled)', async () => { + await fs.mkdir(path.dirname(settingsPath()), { recursive: true }) + await fs.writeFile(settingsPath(), JSON.stringify({ traceCapture: { enabled: true } })) + expect(readNonEssentialTrafficDisabledSync()).toBe(true) + }) + + test('applyNonEssentialTrafficSetting maps a disabled setting into the process env', () => { + applyNonEssentialTrafficSetting() + expect(process.env[NON_ESSENTIAL_TRAFFIC_ENV]).toBe('1') + }) + + test('applyNonEssentialTrafficSetting does not override an explicitly truthy env var', async () => { + process.env[NON_ESSENTIAL_TRAFFIC_ENV] = '1' + await fs.mkdir(path.dirname(settingsPath()), { recursive: true }) + await fs.writeFile(settingsPath(), JSON.stringify({ nonEssentialTraffic: { disabled: false } })) + applyNonEssentialTrafficSetting() + expect(process.env[NON_ESSENTIAL_TRAFFIC_ENV]).toBe('1') + }) + + test('applyNonEssentialTrafficSetting treats a falsy env value ("0") as unset and follows the setting', async () => { + process.env[NON_ESSENTIAL_TRAFFIC_ENV] = '0' + await fs.mkdir(path.dirname(settingsPath()), { recursive: true }) + await fs.writeFile(settingsPath(), JSON.stringify({ nonEssentialTraffic: { disabled: false } })) + applyNonEssentialTrafficSetting() + expect(process.env[NON_ESSENTIAL_TRAFFIC_ENV]).toBeUndefined() + + process.env[NON_ESSENTIAL_TRAFFIC_ENV] = '0' + await fs.writeFile(settingsPath(), JSON.stringify({ nonEssentialTraffic: { disabled: true } })) + applyNonEssentialTrafficSetting() + expect(process.env[NON_ESSENTIAL_TRAFFIC_ENV]).toBe('1') + }) + + test('applyNonEssentialTrafficSetting leaves the env unset when the flag is off', async () => { + await fs.mkdir(path.dirname(settingsPath()), { recursive: true }) + await fs.writeFile(settingsPath(), JSON.stringify({ nonEssentialTraffic: { disabled: false } })) + applyNonEssentialTrafficSetting() + expect(process.env[NON_ESSENTIAL_TRAFFIC_ENV]).toBeUndefined() + }) + + test('updateNonEssentialTrafficDisabled persists the setting and syncs the env', async () => { + await updateNonEssentialTrafficDisabled(true) + expect(process.env[NON_ESSENTIAL_TRAFFIC_ENV]).toBe('1') + const persisted = JSON.parse(await fs.readFile(settingsPath(), 'utf-8')) + expect(persisted.nonEssentialTraffic).toEqual({ disabled: true }) + + await updateNonEssentialTrafficDisabled(false) + expect(process.env[NON_ESSENTIAL_TRAFFIC_ENV]).toBeUndefined() + const persistedAfter = JSON.parse(await fs.readFile(settingsPath(), 'utf-8')) + expect(persistedAfter.nonEssentialTraffic).toEqual({ disabled: false }) + }) + + test('syncNonEssentialTrafficEnv sets and clears the env var', () => { + syncNonEssentialTrafficEnv(true) + expect(process.env[NON_ESSENTIAL_TRAFFIC_ENV]).toBe('1') + syncNonEssentialTrafficEnv(false) + expect(process.env[NON_ESSENTIAL_TRAFFIC_ENV]).toBeUndefined() + }) + + test('re-enabling traffic clears the official marketplace auto-install policy_blocked state', async () => { + const config = getGlobalConfig() + const previous = { + attempted: config.officialMarketplaceAutoInstallAttempted, + installed: config.officialMarketplaceAutoInstalled, + failReason: config.officialMarketplaceAutoInstallFailReason, + retryCount: config.officialMarketplaceAutoInstallRetryCount, + lastAttempt: config.officialMarketplaceAutoInstallLastAttemptTime, + nextRetry: config.officialMarketplaceAutoInstallNextRetryTime, + } + config.officialMarketplaceAutoInstallAttempted = true + config.officialMarketplaceAutoInstalled = false + config.officialMarketplaceAutoInstallFailReason = 'policy_blocked' + config.officialMarketplaceAutoInstallRetryCount = 2 + config.officialMarketplaceAutoInstallLastAttemptTime = 1 + config.officialMarketplaceAutoInstallNextRetryTime = 2 + + try { + await updateNonEssentialTrafficDisabled(false) + + const after = getGlobalConfig() + expect(after.officialMarketplaceAutoInstallAttempted).toBe(false) + expect(after.officialMarketplaceAutoInstalled).toBe(false) + expect(after.officialMarketplaceAutoInstallFailReason).toBeUndefined() + expect(after.officialMarketplaceAutoInstallRetryCount).toBeUndefined() + expect(after.officialMarketplaceAutoInstallNextRetryTime).toBeUndefined() + } finally { + const restored = getGlobalConfig() + restored.officialMarketplaceAutoInstallAttempted = previous.attempted + restored.officialMarketplaceAutoInstalled = previous.installed + restored.officialMarketplaceAutoInstallFailReason = previous.failReason + restored.officialMarketplaceAutoInstallRetryCount = previous.retryCount + restored.officialMarketplaceAutoInstallLastAttemptTime = previous.lastAttempt + restored.officialMarketplaceAutoInstallNextRetryTime = previous.nextRetry + } + }) +}) + +describe('GET/PUT /api/privacy/non-essential-traffic', () => { + test('GET returns the default disabled=true', async () => { + const res = await handleApiRequest( + new Request('http://localhost/api/privacy/non-essential-traffic'), + new URL('http://localhost/api/privacy/non-essential-traffic'), + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ disabled: true }) + }) + + test('PUT persists the flag and applies it to the process env', async () => { + const res = await handleApiRequest( + new Request('http://localhost/api/privacy/non-essential-traffic', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ disabled: false }), + }), + new URL('http://localhost/api/privacy/non-essential-traffic'), + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ disabled: false }) + expect(process.env[NON_ESSENTIAL_TRAFFIC_ENV]).toBeUndefined() + expect(await readNonEssentialTrafficDisabled()).toBe(false) + }) + + test('PUT rejects a non-boolean disabled value', async () => { + const res = await handleApiRequest( + new Request('http://localhost/api/privacy/non-essential-traffic', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ disabled: 'yes' }), + }), + new URL('http://localhost/api/privacy/non-essential-traffic'), + ) + expect(res.status).toBe(400) + }) +}) diff --git a/src/server/api/privacy.ts b/src/server/api/privacy.ts new file mode 100644 index 00000000..2cf2fef0 --- /dev/null +++ b/src/server/api/privacy.ts @@ -0,0 +1,52 @@ +/** + * Privacy REST API + * + * GET /api/privacy/non-essential-traffic — 获取"禁用非必要外部流量"开关 + * PUT /api/privacy/non-essential-traffic — 更新开关(同步当前进程 env) + */ + +import { ApiError, errorResponse } from '../middleware/errorHandler.js' +import { + readNonEssentialTrafficDisabled, + updateNonEssentialTrafficDisabled, +} from '../../services/api/nonEssentialTraffic.js' + +export async function handlePrivacyApi( + req: Request, + _url: URL, + segments: string[], +): Promise { + try { + const sub = segments[2] // 'non-essential-traffic' | undefined + + if (sub === 'non-essential-traffic') { + if (req.method === 'GET') { + return Response.json({ disabled: await readNonEssentialTrafficDisabled() }) + } + if (req.method === 'PUT') { + const body = await parseJsonBody(req) + if (typeof body.disabled !== 'boolean') { + throw ApiError.badRequest('Missing or invalid "disabled" in request body') + } + return Response.json(await updateNonEssentialTrafficDisabled(body.disabled)) + } + throw methodNotAllowed(req.method) + } + + throw ApiError.notFound(`Unknown privacy endpoint: ${sub}`) + } catch (error) { + return errorResponse(error) + } +} + +function methodNotAllowed(method: string): ApiError { + return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED') +} + +async function parseJsonBody(req: Request): Promise> { + try { + return (await req.json()) as Record + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} diff --git a/src/server/index.ts b/src/server/index.ts index f705953e..7eaf8cdf 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -24,6 +24,7 @@ import { conversationService } from './services/conversationService.js' import { OPENAI_CODEX_REDIRECT_PATH } from '../services/openaiAuth/client.js' import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js' import { enableConfigs } from '../utils/config.js' +import { applyNonEssentialTrafficSetting } from '../services/api/nonEssentialTraffic.js' import { diagnosticsService } from './services/diagnosticsService.js' import { ensurePersistentStorageUpgraded } from './services/persistentStorageMigrations.js' import { handleStaticH5Request } from './staticH5.js' @@ -204,6 +205,9 @@ function originFromUrl(value: string | null): string | null { export function startServer(port = PORT, host = HOST) { enableConfigs() + // Map the "禁用非必要外部流量" setting into the process env before anything + // else can fire nonessential traffic; spawned CLI sessions inherit it. + applyNonEssentialTrafficSetting() // Warm the synchronous disconnect-grace cache from managed settings so the // first client disconnect honors the configured value (issue #764). void refreshDisconnectGraceMs() diff --git a/src/server/router.ts b/src/server/router.ts index af80655d..538a262d 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -29,6 +29,7 @@ import { handleOpenTargetsApi } from './api/open-targets.js' import { handleMemoryApi } from './api/memory.js' import { handleDesktopUiApi } from './api/desktop-ui.js' import { handleTracesApi } from './api/traces.js' +import { handlePrivacyApi } from './api/privacy.js' export async function handleApiRequest(req: Request, url: URL): Promise { const path = url.pathname @@ -132,6 +133,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise { } export async function providerFetch(source: MarketSource, url: string): Promise { + // 禁用非必要外部流量时,Skills 市场不产生任何出站请求。 + if (isEssentialTrafficOnly()) { + const error = new MarketUpstreamError( + source, + MARKET_ERROR_CODES.upstreamError, + `${source} provider disabled by non-essential traffic setting`, + ) + recordSourceFailure(source, error.message) + throw error + } + if (isProviderDisabled(source)) { const error = new MarketUpstreamError(source, MARKET_ERROR_CODES.upstreamError, `${source} provider disabled`) recordSourceFailure(source, error.message) diff --git a/src/services/api/nonEssentialTraffic.ts b/src/services/api/nonEssentialTraffic.ts new file mode 100644 index 00000000..c6e4dbb8 --- /dev/null +++ b/src/services/api/nonEssentialTraffic.ts @@ -0,0 +1,149 @@ +/** + * "禁用非必要外部流量"开关(cc-haha/settings.json 托管)。 + * + * cc-haha 与 Claude Code 本身已无强绑定,除用户主动配置 Anthropic provider 外, + * 默认屏蔽所有非必要出站流量(1P 事件遥测、MCP 官方注册表、更新检查、官方插件 + * 市场、Skills 市场等),避免企业安全软件对 anthropic.com 域名的告警(#1173)。 + * + * 落地方式: + * - 设置写入 ~/.claude/cc-haha/settings.json 的 `nonEssentialTraffic.disabled` + * - server 启动时(applyNonEssentialTrafficSetting)把开关映射为进程内的 + * CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1,会话子进程继承该 env, + * 覆盖 CLI 侧全部 essential-traffic 门控(src/utils/privacyLevel.ts) + * - 运行中切换时由 updateNonEssentialTrafficDisabled 同步 env + */ + +import { randomUUID } from 'crypto' +import { existsSync, readFileSync } from 'fs' +import { promises as fs } from 'fs' +import { dirname, join } from 'path' +import { getClaudeConfigHomeDir, isEnvTruthy } from '../../utils/envUtils.js' +import { saveGlobalConfig } from '../../utils/config.js' + +const NON_ESSENTIAL_TRAFFIC_KEY = 'nonEssentialTraffic' +export const NON_ESSENTIAL_TRAFFIC_ENV = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' + +export type NonEssentialTrafficSettings = { + /** true = 禁用非必要外部流量(默认开启) */ + disabled: boolean +} + +/** 默认开启禁用:cc-haha 与 Claude Code 已无强绑定,新用户默认不产生非必要流量。 */ +export function defaultNonEssentialTrafficSettings(): NonEssentialTrafficSettings { + return { disabled: true } +} + +function getManagedSettingsPath(scope = getClaudeConfigHomeDir()): string { + return join(scope, 'cc-haha', 'settings.json') +} + +function normalizeNonEssentialTrafficSettings( + settings: Record, +): NonEssentialTrafficSettings { + const value = settings[NON_ESSENTIAL_TRAFFIC_KEY] + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return defaultNonEssentialTrafficSettings() + } + const record = value as Record + return { + disabled: typeof record.disabled === 'boolean' ? record.disabled : true, + } +} + +function readManagedSettingsSync(scope = getClaudeConfigHomeDir()): Record { + try { + const filePath = getManagedSettingsPath(scope) + if (!existsSync(filePath)) return {} + const parsed = JSON.parse(readFileSync(filePath, 'utf-8')) as unknown + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + return {} + } +} + +async function readManagedSettings(scope = getClaudeConfigHomeDir()): Promise> { + try { + const parsed = JSON.parse(await fs.readFile(getManagedSettingsPath(scope), 'utf-8')) as unknown + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + return {} + } +} + +async function writeManagedSettings( + settings: Record, + scope = getClaudeConfigHomeDir(), +): Promise { + const filePath = getManagedSettingsPath(scope) + const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomUUID()}` + await fs.mkdir(dirname(filePath), { recursive: true }) + await fs.writeFile(tmpFile, `${JSON.stringify(settings, null, 2)}\n`, 'utf-8') + await fs.rename(tmpFile, filePath) +} + +/** 同步读取(用于 server 启动早期)。 */ +export function readNonEssentialTrafficDisabledSync(): boolean { + return normalizeNonEssentialTrafficSettings(readManagedSettingsSync()).disabled +} + +export async function readNonEssentialTrafficDisabled(): Promise { + return normalizeNonEssentialTrafficSettings(await readManagedSettings()).disabled +} + +/** + * server 启动时调用:把设置映射为进程内 env,让本进程与派生 CLI 子进程 + * 都走 essential-traffic 门控。 + * + * 显式 truthy env('1'/'true' 等)表示用户显式启用 essential-traffic, + * 优先于设置不覆盖;falsy('0'/'false')不算显式启用,直接按设置覆盖, + * 否则 privacyLevel 的 truthy 判断会把 '0' 当作启用,UI 开关与实际行为不一致。 + */ +export function applyNonEssentialTrafficSetting(): void { + if (isEnvTruthy(process.env[NON_ESSENTIAL_TRAFFIC_ENV])) return + if (readNonEssentialTrafficDisabledSync()) { + process.env[NON_ESSENTIAL_TRAFFIC_ENV] = '1' + } else { + delete process.env[NON_ESSENTIAL_TRAFFIC_ENV] + } +} + +/** 运行中同步 env:开关关闭时删除 env,恢复非必要流量。 */ +export function syncNonEssentialTrafficEnv(disabled: boolean): void { + if (disabled) { + process.env[NON_ESSENTIAL_TRAFFIC_ENV] = '1' + } else { + delete process.env[NON_ESSENTIAL_TRAFFIC_ENV] + } +} + +/** 更新设置并同步当前进程 env(桌面 UI 切换开关后立即生效)。 */ +export async function updateNonEssentialTrafficDisabled( + disabled: boolean, +): Promise { + const scope = getClaudeConfigHomeDir() + const current = await readManagedSettings(scope) + const next = { + ...current, + [NON_ESSENTIAL_TRAFFIC_KEY]: { disabled }, + } + await writeManagedSettings(next, scope) + syncNonEssentialTrafficEnv(disabled) + if (!disabled) { + // 重新允许流量时,解除官方插件市场自动安装的 policy_blocked 永久标记 + // (开启禁用时记录该标记,避免用户关闭开关后市场永远无法自动安装)。 + saveGlobalConfig(currentConfig => ({ + ...currentConfig, + officialMarketplaceAutoInstallAttempted: false, + officialMarketplaceAutoInstalled: false, + officialMarketplaceAutoInstallFailReason: undefined, + officialMarketplaceAutoInstallRetryCount: undefined, + officialMarketplaceAutoInstallLastAttemptTime: undefined, + officialMarketplaceAutoInstallNextRetryTime: undefined, + })) + } + return { disabled } +} diff --git a/src/tools/WebFetchTool/utils.test.ts b/src/tools/WebFetchTool/utils.test.ts index 3891c0a9..e6e480cb 100644 --- a/src/tools/WebFetchTool/utils.test.ts +++ b/src/tools/WebFetchTool/utils.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { shouldSkipWebFetchPreflight } from './utils.js' +import { checkDomainBlocklist, shouldSkipWebFetchPreflight } from './utils.js' + +const NON_ESSENTIAL_TRAFFIC_ENV = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' describe('shouldSkipWebFetchPreflight', () => { const originalDesktopServerUrl = process.env.CC_HAHA_DESKTOP_SERVER_URL @@ -40,3 +42,32 @@ describe('shouldSkipWebFetchPreflight', () => { expect(shouldSkipWebFetchPreflight({})).toBe(false) }) }) + +describe('checkDomainBlocklist under essential-traffic', () => { + let originalNonEssentialTrafficEnv: string | undefined + + beforeEach(() => { + originalNonEssentialTrafficEnv = process.env[NON_ESSENTIAL_TRAFFIC_ENV] + process.env[NON_ESSENTIAL_TRAFFIC_ENV] = '1' + }) + + afterEach(() => { + if (originalNonEssentialTrafficEnv === undefined) { + delete process.env[NON_ESSENTIAL_TRAFFIC_ENV] + } else { + process.env[NON_ESSENTIAL_TRAFFIC_ENV] = originalNonEssentialTrafficEnv + } + }) + + test('allows the domain without querying Anthropic when non-essential traffic is disabled', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (() => { + throw new Error('must not be reached') + }) as typeof fetch + try { + expect(await checkDomainBlocklist('example.com')).toEqual({ status: 'allowed' }) + } finally { + globalThis.fetch = originalFetch + } + }) +}) diff --git a/src/tools/WebFetchTool/utils.ts b/src/tools/WebFetchTool/utils.ts index d1f5dfd9..e14a5a9d 100644 --- a/src/tools/WebFetchTool/utils.ts +++ b/src/tools/WebFetchTool/utils.ts @@ -7,6 +7,7 @@ import { import { queryHaiku } from '../../services/api/claude.js' import { AbortError } from '../../utils/errors.js' import { getWebFetchUserAgent } from '../../utils/http.js' +import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' import { logError } from '../../utils/log.js' import { isBinaryContentType, @@ -192,6 +193,11 @@ export async function checkDomainBlocklist( if (DOMAIN_CHECK_CACHE.has(domain)) { return { status: 'allowed' } } + // 禁用非必要外部流量时不查询 Anthropic 的 domain_info(api.anthropic.com), + // 与 skipWebFetchPreflight=true 的企业语义一致:视为允许。 + if (isEssentialTrafficOnly()) { + return { status: 'allowed' } + } try { const response = await axios.get( `https://api.anthropic.com/api/web/domain_info?domain=${encodeURIComponent(domain)}`, diff --git a/src/utils/apiPreconnect.ts b/src/utils/apiPreconnect.ts index 7ee24c1b..852ad261 100644 --- a/src/utils/apiPreconnect.ts +++ b/src/utils/apiPreconnect.ts @@ -25,6 +25,7 @@ import { getOauthConfig } from '../constants/oauth.js' import { isEnvTruthy } from './envUtils.js' +import { isEssentialTrafficOnly } from './privacyLevel.js' let fired = false @@ -32,6 +33,12 @@ export function preconnectAnthropicApi(): void { if (fired) return fired = true + // Skip when nonessential traffic is disabled: the preconnect is a bare + // handshake to the Anthropic endpoint that enterprise firewalls still log. + if (isEssentialTrafficOnly()) { + return + } + // Skip if using a cloud provider — different endpoint + auth if ( isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || diff --git a/src/utils/config.ts b/src/utils/config.ts index 8c6741ed..6b9c7755 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -28,7 +28,7 @@ import * as lockfile from './lockfile.js' import { logError } from './log.js' import type { MemoryType } from './memory/types.js' import { normalizePathForConfigKey } from './path.js' -import { getEssentialTrafficOnlyReason } from './privacyLevel.js' +import { getEssentialTrafficOnlyReason, isEssentialTrafficOnly } from './privacyLevel.js' import { getManagedFilePath } from './settings/managedPath.js' import type { ThemeSetting } from './theme.js' @@ -1715,11 +1715,14 @@ export function isAutoUpdaterDisabled(): boolean { * This checks if the auto-updater is disabled AND the FORCE_AUTOUPDATE_PLUGINS * env var is not set to 'true'. The env var allows forcing plugin autoupdate * even when the auto-updater is otherwise disabled. + * Non-essential traffic is always skipped (marketplace refresh hits + * github.com / downloads.claude.ai). */ export function shouldSkipPluginAutoupdate(): boolean { return ( - isAutoUpdaterDisabled() && - !isEnvTruthy(process.env.FORCE_AUTOUPDATE_PLUGINS) + isEssentialTrafficOnly() || + (isAutoUpdaterDisabled() && + !isEnvTruthy(process.env.FORCE_AUTOUPDATE_PLUGINS)) ) } diff --git a/src/utils/plugins/officialMarketplaceStartupCheck.test.ts b/src/utils/plugins/officialMarketplaceStartupCheck.test.ts new file mode 100644 index 00000000..e0f3de3b --- /dev/null +++ b/src/utils/plugins/officialMarketplaceStartupCheck.test.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { getGlobalConfig } from '../config.js' +import { checkAndInstallOfficialMarketplace } from './officialMarketplaceStartupCheck.js' + +const NON_ESSENTIAL_TRAFFIC_ENV = 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' +let originalEnv: string | undefined + +beforeEach(() => { + originalEnv = process.env[NON_ESSENTIAL_TRAFFIC_ENV] + process.env[NON_ESSENTIAL_TRAFFIC_ENV] = '1' +}) + +afterEach(() => { + if (originalEnv === undefined) delete process.env[NON_ESSENTIAL_TRAFFIC_ENV] + else process.env[NON_ESSENTIAL_TRAFFIC_ENV] = originalEnv + // checkAndInstallOfficialMarketplace records its skip via saveGlobalConfig; + // restore the shared test config so other suites are unaffected. + const config = getGlobalConfig() + config.officialMarketplaceAutoInstallAttempted = false + config.officialMarketplaceAutoInstalled = false + config.officialMarketplaceAutoInstallFailReason = undefined +}) + +describe('official marketplace auto-install under essential-traffic', () => { + it('skips without touching GCS or git when non-essential traffic is disabled', async () => { + const result = await checkAndInstallOfficialMarketplace() + + expect(result.skipped).toBe(true) + expect(result.reason).toBe('policy_blocked') + expect(getGlobalConfig().officialMarketplaceAutoInstallFailReason).toBe('policy_blocked') + }) +}) diff --git a/src/utils/plugins/officialMarketplaceStartupCheck.ts b/src/utils/plugins/officialMarketplaceStartupCheck.ts index d8fb2eba..97ea8d38 100644 --- a/src/utils/plugins/officialMarketplaceStartupCheck.ts +++ b/src/utils/plugins/officialMarketplaceStartupCheck.ts @@ -14,6 +14,7 @@ import { logEvent } from '../../services/analytics/index.js' import { getGlobalConfig, saveGlobalConfig } from '../config.js' import { logForDebugging } from '../debug.js' import { isEnvTruthy } from '../envUtils.js' +import { isEssentialTrafficOnly } from '../privacyLevel.js' import { toError } from '../errors.js' import { logError } from '../log.js' import { checkGitAvailable, markGitUnavailable } from './gitAvailability.js' @@ -147,6 +148,20 @@ export type OfficialMarketplaceCheckResult = { export async function checkAndInstallOfficialMarketplace(): Promise { const config = getGlobalConfig() + // 禁用非必要外部流量时,不自动拉取官方插件市场(downloads.claude.ai)。 + if (isEssentialTrafficOnly()) { + logForDebugging( + 'Official marketplace auto-install skipped: non-essential traffic disabled', + ) + saveGlobalConfig(current => ({ + ...current, + officialMarketplaceAutoInstallAttempted: true, + officialMarketplaceAutoInstalled: false, + officialMarketplaceAutoInstallFailReason: 'policy_blocked', + })) + return { installed: false, skipped: true, reason: 'policy_blocked' } + } + // Check if we should retry installation if (!shouldRetryInstallation(config)) { const reason: OfficialMarketplaceSkipReason =