From 302bbd4ce05a51fab97cdb2ed5b58b07c9d36d72 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 14:00:33 +0900 Subject: [PATCH] =?UTF-8?q?fix(v032):=20healthCheck=20reason=20PII=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=82=B9=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit err.message 안에 LAN endpoint URL (예: 192.168.x.x:11434) 이 포함될 수 있어 telemetry 파일에 PII 우회 노출. v0.2.3.1 in-app endpoint UI 가 LAN 사용을 흔하게 만들어 노출 경로 확대. classifyFetchError 로 error class 분류 (network/timeout/dns/other) 후 reason: 'unreachable:{class}' 형태만 emit. host/IP 노출 0. --- src/main/ai/LocalOllamaProvider.ts | 11 +++++++- tests/unit/LocalOllamaProvider.test.ts | 38 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts index 9e117b0..8ad96fb 100644 --- a/src/main/ai/LocalOllamaProvider.ts +++ b/src/main/ai/LocalOllamaProvider.ts @@ -5,6 +5,14 @@ import { buildVisionPrompt } from './visionPrompt.js'; import type { GenerateInput, GenerateOptions, HealthResult, InferenceProvider } from './InferenceProvider.js'; import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js'; +function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other' { + const msg = ((e as Error)?.message ?? '').toLowerCase(); + if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout'; + if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network'; + if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns'; + return 'other'; +} + export interface LocalOllamaOptions { endpoint?: string; model?: string; @@ -119,7 +127,8 @@ export class LocalOllamaProvider implements InferenceProvider { return found ? { ok: true, model: this.model } : { ok: false, reason: `${this.model} not installed` }; } catch (err) { - return { ok: false, reason: `unreachable: ${(err as Error).message}` }; + const cls = classifyFetchError(err); + return { ok: false, reason: `unreachable:${cls}` }; } } } diff --git a/tests/unit/LocalOllamaProvider.test.ts b/tests/unit/LocalOllamaProvider.test.ts index f254bb5..b69da29 100644 --- a/tests/unit/LocalOllamaProvider.test.ts +++ b/tests/unit/LocalOllamaProvider.test.ts @@ -110,6 +110,44 @@ describe('LocalOllamaProvider', () => { expect(provider.name).toBe('local-ollama/gemma4:26b'); }); + describe('healthCheck PII reason masking', () => { + it('classifies ECONNREFUSED as network', async () => { + mock.get('http://192.168.1.5:11434').intercept({ path: '/api/tags', method: 'GET' }) + .replyWithError(new Error('connect ECONNREFUSED 192.168.1.5:11434')); + const provider = new LocalOllamaProvider({ endpoint: 'http://192.168.1.5:11434' }); + const h = await provider.healthCheck(); + expect(h.ok).toBe(false); + expect(h.reason).toBe('unreachable:network'); + expect(h.reason).not.toContain('192.168.1.5'); + }); + + it('classifies AbortError/timeout as timeout', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }) + .replyWithError(new Error('The operation was aborted due to timeout')); + const h = await new LocalOllamaProvider().healthCheck(); + expect(h.ok).toBe(false); + expect(h.reason).toBe('unreachable:timeout'); + }); + + it('classifies ENOTFOUND as dns', async () => { + mock.get('http://nonexistent.local:11434').intercept({ path: '/api/tags', method: 'GET' }) + .replyWithError(new Error('getaddrinfo ENOTFOUND nonexistent.local')); + const provider = new LocalOllamaProvider({ endpoint: 'http://nonexistent.local:11434' }); + const h = await provider.healthCheck(); + expect(h.ok).toBe(false); + expect(h.reason).toBe('unreachable:dns'); + expect(h.reason).not.toContain('nonexistent.local'); + }); + + it('falls back to other for unknown errors', async () => { + mock.get('http://localhost:11434').intercept({ path: '/api/tags', method: 'GET' }) + .replyWithError(new Error('something weird happened')); + const h = await new LocalOllamaProvider().healthCheck(); + expect(h.ok).toBe(false); + expect(h.reason).toBe('unreachable:other'); + }); + }); + describe('vision path (v0.3.1 Cut F)', () => { it('visionModel + images → body.images + model=visionModel + buildVisionPrompt', async () => { let capturedBody: string = '';