diff --git a/desktop/src/__tests__/diagnosticsSettings.test.tsx b/desktop/src/__tests__/diagnosticsSettings.test.tsx new file mode 100644 index 00000000..e5827770 --- /dev/null +++ b/desktop/src/__tests__/diagnosticsSettings.test.tsx @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { Settings } from '../pages/Settings' +import { useSettingsStore } from '../stores/settingsStore' +import { useUIStore } from '../stores/uiStore' + +const diagnosticsApiMock = vi.hoisted(() => ({ + getStatus: vi.fn(), + getEvents: vi.fn(), + exportBundle: vi.fn(), + openLogDir: vi.fn(), + clear: vi.fn(), +})) + +vi.mock('../api/diagnostics', () => ({ + diagnosticsApi: diagnosticsApiMock, +})) + +vi.mock('../stores/providerStore', () => ({ + useProviderStore: () => ({ + providers: [], + activeId: null, + hasLoadedProviders: true, + presets: [], + isLoading: false, + isPresetsLoading: false, + fetchProviders: vi.fn(), + fetchPresets: 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('../api/providers', () => ({ + providersApi: { + getSettings: vi.fn().mockResolvedValue({}), + updateSettings: vi.fn().mockResolvedValue({}), + }, +})) + +vi.mock('../components/settings/ClaudeOfficialLogin', () => ({ + ClaudeOfficialLogin: () =>
, +})) + +vi.mock('../pages/AdapterSettings', () => ({ + AdapterSettings: () =>
, +})) + +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 > Diagnostics tab', () => { + beforeEach(() => { + diagnosticsApiMock.getStatus.mockResolvedValue({ + logDir: '/tmp/claude/cc-haha/diagnostics', + diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl', + runtimeErrorsPath: '/tmp/claude/cc-haha/diagnostics/runtime-errors.log', + exportDir: '/tmp/claude/cc-haha/diagnostics/exports', + retentionDays: 7, + maxBytes: 50 * 1024 * 1024, + totalBytes: 4096, + eventCount: 2, + recentErrorCount: 1, + lastEventAt: '2026-05-02T00:00:00.000Z', + }) + diagnosticsApiMock.getEvents.mockResolvedValue({ + events: [{ + id: 'event-1', + timestamp: '2026-05-02T00:00:00.000Z', + type: 'cli_start_failed', + severity: 'error', + summary: 'CLI exited during startup with code 1', + sessionId: 'session-1', + }], + }) + diagnosticsApiMock.exportBundle.mockResolvedValue({ + bundle: { + path: '/tmp/claude/cc-haha/diagnostics/exports/cc-haha-diagnostics.tar.gz', + fileName: 'cc-haha-diagnostics.tar.gz', + bytes: 1024, + }, + }) + diagnosticsApiMock.openLogDir.mockResolvedValue({ ok: true }) + diagnosticsApiMock.clear.mockResolvedValue({ ok: true }) + + useSettingsStore.setState({ locale: 'en' }) + useUIStore.setState({ pendingSettingsTab: null, toasts: [] }) + }) + + it('shows diagnostics status, actions, and recent events', async () => { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + + expect(await screen.findByText('Log directory')).toBeInTheDocument() + expect(screen.getByText('/tmp/claude/cc-haha/diagnostics')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Export Bundle/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Copy Error Summary/i })).toBeInTheDocument() + expect(screen.getByText('cli_start_failed')).toBeInTheDocument() + expect(screen.getByText('CLI exited during startup with code 1')).toBeInTheDocument() + }) + + it('exports a diagnostics bundle from the settings page', async () => { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Export Bundle/i })) + + await waitFor(() => { + expect(diagnosticsApiMock.exportBundle).toHaveBeenCalled() + }) + expect(await screen.findByText('/tmp/claude/cc-haha/diagnostics/exports/cc-haha-diagnostics.tar.gz')).toBeInTheDocument() + }) + + it('copies the recent error summary with the legacy clipboard fallback', async () => { + const originalClipboard = navigator.clipboard + const originalExecCommand = document.execCommand + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn().mockReturnValue(true), + }) + const execCommand = vi.mocked(document.execCommand) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')), + }, + }) + + try { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Copy Error Summary/i })) + + await waitFor(() => { + expect(execCommand).toHaveBeenCalledWith('copy') + }) + const toasts = useUIStore.getState().toasts + expect(toasts[toasts.length - 1]?.message).toBe('Error summary copied.') + } finally { + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: originalExecCommand, + }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }) + } + }) +}) diff --git a/desktop/src/api/diagnostics.ts b/desktop/src/api/diagnostics.ts new file mode 100644 index 00000000..3fabac21 --- /dev/null +++ b/desktop/src/api/diagnostics.ts @@ -0,0 +1,40 @@ +import { api } from './client' + +export type DiagnosticSeverity = 'debug' | 'info' | 'warn' | 'error' + +export type DiagnosticEvent = { + id: string + timestamp: string + type: string + severity: DiagnosticSeverity + summary: string + sessionId?: string + details?: unknown +} + +export type DiagnosticsStatus = { + logDir: string + diagnosticsPath: string + runtimeErrorsPath: string + exportDir: string + retentionDays: number + maxBytes: number + totalBytes: number + eventCount: number + recentErrorCount: number + lastEventAt: string | null +} + +export type DiagnosticsBundle = { + path: string + fileName: string + bytes: number +} + +export const diagnosticsApi = { + getStatus: () => api.get('/api/diagnostics/status'), + getEvents: (limit = 100) => api.get<{ events: DiagnosticEvent[] }>(`/api/diagnostics/events?limit=${limit}`), + exportBundle: () => api.post<{ bundle: DiagnosticsBundle }>('/api/diagnostics/export', undefined, { timeout: 60_000 }), + openLogDir: () => api.post<{ ok: true }>('/api/diagnostics/open-log-dir'), + clear: () => api.delete<{ ok: true }>('/api/diagnostics'), +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 76335ea0..11a426a7 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -94,6 +94,7 @@ export const en = { 'settings.tab.skills': 'Skills', 'settings.tab.mcp': 'MCP', 'settings.tab.plugins': 'Plugins', + 'settings.tab.diagnostics': 'Diagnostics', // Settings > Terminal 'settings.terminal.title': 'Terminal', @@ -111,6 +112,34 @@ export const en = { 'settings.terminal.status.unavailable': 'Unavailable', 'terminal.newTab': 'New Terminal', + // Settings > Diagnostics + 'settings.diagnostics.title': 'Diagnostics', + 'settings.diagnostics.description': 'Server and CLI runtime logs for debugging startup, provider, and session failures.', + 'settings.diagnostics.refresh': 'Refresh', + 'settings.diagnostics.totalSize': 'Log size', + 'settings.diagnostics.events': 'Events', + 'settings.diagnostics.recentErrors': '24h warnings', + 'settings.diagnostics.retention': 'Retention', + 'settings.diagnostics.retentionValue': '{days} days / {size}', + 'settings.diagnostics.logDirectory': 'Log directory', + 'settings.diagnostics.openDirectory': 'Open', + 'settings.diagnostics.exportBundle': 'Export Bundle', + 'settings.diagnostics.copySummary': 'Copy Error Summary', + 'settings.diagnostics.clearLogs': 'Clear Logs', + 'settings.diagnostics.recentEvents': 'Recent Events', + 'settings.diagnostics.privacyNote': 'Exported diagnostics are sanitized and do not include chat content, file contents, full environment variables, or API keys.', + 'settings.diagnostics.noEvents': 'No diagnostic events yet.', + 'settings.diagnostics.noRecentErrors': 'No recent warnings or errors.', + 'settings.diagnostics.loadFailed': 'Failed to load diagnostics.', + 'settings.diagnostics.openFailed': 'Failed to open diagnostics directory.', + 'settings.diagnostics.exportFailed': 'Failed to export diagnostics bundle.', + 'settings.diagnostics.exported': 'Exported {file}', + 'settings.diagnostics.summaryCopied': 'Error summary copied.', + 'settings.diagnostics.copyFailed': 'Failed to copy error summary.', + 'settings.diagnostics.confirmClear': 'Clear all local diagnostic logs and exported bundles?', + 'settings.diagnostics.cleared': 'Diagnostics cleared.', + 'settings.diagnostics.clearFailed': 'Failed to clear diagnostics.', + // Settings > Claude Official Login 'settings.claudeOfficialLogin.intro': 'Using official Claude models requires signing in to your Claude.ai account. Click the button below to open the official Claude login page in your browser; you\'ll be returned here after authorizing.', 'settings.claudeOfficialLogin.loginButton': 'Sign in to Claude', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 04da9b7b..7e01828e 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -96,6 +96,7 @@ export const zh: Record = { 'settings.tab.skills': '技能', 'settings.tab.mcp': 'MCP', 'settings.tab.plugins': '插件', + 'settings.tab.diagnostics': '诊断', // Settings > Terminal 'settings.terminal.title': '终端', @@ -113,6 +114,34 @@ export const zh: Record = { 'settings.terminal.status.unavailable': '不可用', 'terminal.newTab': '新建终端', + // Settings > Diagnostics + 'settings.diagnostics.title': '诊断', + 'settings.diagnostics.description': '记录服务端与 CLI 的启动、服务商、会话运行错误,便于复现和定位问题。', + 'settings.diagnostics.refresh': '刷新', + 'settings.diagnostics.totalSize': '日志大小', + 'settings.diagnostics.events': '事件数', + 'settings.diagnostics.recentErrors': '24h 警告', + 'settings.diagnostics.retention': '保留策略', + 'settings.diagnostics.retentionValue': '{days} 天 / {size}', + 'settings.diagnostics.logDirectory': '日志目录', + 'settings.diagnostics.openDirectory': '打开', + 'settings.diagnostics.exportBundle': '导出诊断包', + 'settings.diagnostics.copySummary': '复制错误摘要', + 'settings.diagnostics.clearLogs': '清理日志', + 'settings.diagnostics.recentEvents': '最近事件', + 'settings.diagnostics.privacyNote': '导出的诊断包会脱敏,不包含聊天内容、文件内容、完整环境变量或 API Key。', + 'settings.diagnostics.noEvents': '暂无诊断事件。', + 'settings.diagnostics.noRecentErrors': '最近没有警告或错误。', + 'settings.diagnostics.loadFailed': '加载诊断信息失败。', + 'settings.diagnostics.openFailed': '打开诊断目录失败。', + 'settings.diagnostics.exportFailed': '导出诊断包失败。', + 'settings.diagnostics.exported': '已导出 {file}', + 'settings.diagnostics.summaryCopied': '错误摘要已复制。', + 'settings.diagnostics.copyFailed': '复制错误摘要失败。', + 'settings.diagnostics.confirmClear': '确定清理所有本地诊断日志和已导出的诊断包?', + 'settings.diagnostics.cleared': '诊断日志已清理。', + 'settings.diagnostics.clearFailed': '清理诊断日志失败。', + // Settings > Claude Official Login 'settings.claudeOfficialLogin.intro': '使用官方 Claude 模型需要登录你的 Claude.ai 账号。点击下方按钮,浏览器会打开 Claude 官方登录页面,授权后自动回到这里。', 'settings.claudeOfficialLogin.loginButton': '登录 Claude 账号', diff --git a/desktop/src/pages/DiagnosticsSettings.tsx b/desktop/src/pages/DiagnosticsSettings.tsx new file mode 100644 index 00000000..83b2dd47 --- /dev/null +++ b/desktop/src/pages/DiagnosticsSettings.tsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { diagnosticsApi, type DiagnosticEvent, type DiagnosticsStatus } from '../api/diagnostics' +import { Button } from '../components/shared/Button' +import { copyTextToClipboard } from '../components/chat/clipboard' +import { useTranslation } from '../i18n' +import { formatBytes } from '../lib/formatBytes' +import { useUIStore } from '../stores/uiStore' + +export function DiagnosticsSettings() { + const t = useTranslation() + const addToast = useUIStore((s) => s.addToast) + const [status, setStatus] = useState(null) + const [events, setEvents] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isExporting, setIsExporting] = useState(false) + const [isClearing, setIsClearing] = useState(false) + const [lastExportPath, setLastExportPath] = useState(null) + + const load = useCallback(async () => { + setIsLoading(true) + try { + const [nextStatus, eventResult] = await Promise.all([ + diagnosticsApi.getStatus(), + diagnosticsApi.getEvents(100), + ]) + setStatus(nextStatus) + setEvents(eventResult.events) + } catch (error) { + addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('settings.diagnostics.loadFailed'), + }) + } finally { + setIsLoading(false) + } + }, [addToast, t]) + + useEffect(() => { + void load() + }, [load]) + + const recentErrorSummary = useMemo(() => { + return events + .filter((event) => event.severity === 'error' || event.severity === 'warn') + .slice(0, 20) + .map((event) => `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}: ${event.summary}`) + .join('\n') + }, [events]) + + const handleOpenDir = async () => { + try { + await diagnosticsApi.openLogDir() + } catch (error) { + addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('settings.diagnostics.openFailed'), + }) + } + } + + const handleExport = async () => { + setIsExporting(true) + try { + const { bundle } = await diagnosticsApi.exportBundle() + setLastExportPath(bundle.path) + addToast({ + type: 'success', + message: t('settings.diagnostics.exported', { file: bundle.fileName }), + }) + await load() + } catch (error) { + addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('settings.diagnostics.exportFailed'), + }) + } finally { + setIsExporting(false) + } + } + + const handleCopySummary = async () => { + const text = recentErrorSummary || t('settings.diagnostics.noRecentErrors') + const copied = await copyTextToClipboard(text) + if (copied) { + addToast({ type: 'success', message: t('settings.diagnostics.summaryCopied') }) + return + } + addToast({ type: 'error', message: t('settings.diagnostics.copyFailed') }) + } + + const handleClear = async () => { + if (!window.confirm(t('settings.diagnostics.confirmClear'))) return + setIsClearing(true) + try { + await diagnosticsApi.clear() + setEvents([]) + setStatus(await diagnosticsApi.getStatus()) + setLastExportPath(null) + addToast({ type: 'success', message: t('settings.diagnostics.cleared') }) + } catch (error) { + addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('settings.diagnostics.clearFailed'), + }) + } finally { + setIsClearing(false) + } + } + + return ( +
+
+
+

{t('settings.diagnostics.title')}

+

{t('settings.diagnostics.description')}

+
+ +
+ +
+ + + + +
+ +
+
+
+
{t('settings.diagnostics.logDirectory')}
+
{status?.logDir ?? '-'}
+
+ +
+
+ + + + {lastExportPath && ( + + {lastExportPath} + + )} +
+
+ +
+

{t('settings.diagnostics.recentEvents')}

+

{t('settings.diagnostics.privacyNote')}

+
+ +
+ {events.length === 0 ? ( +
+ {isLoading ? t('common.loading') : t('settings.diagnostics.noEvents')} +
+ ) : ( +
+ {events.map((event) => ( + + ))} +
+ )} +
+
+ ) +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function EventRow({ event }: { event: DiagnosticEvent }) { + const severityClass = + event.severity === 'error' + ? 'text-[var(--color-error)]' + : event.severity === 'warn' + ? 'text-[var(--color-warning)]' + : 'text-[var(--color-text-tertiary)]' + + return ( +
+
+ {new Date(event.timestamp).toLocaleString()} +
+
{event.severity}
+
+
+ {event.type} + {event.sessionId && ( + {event.sessionId} + )} +
+
{event.summary}
+
+
+ ) +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 4fc8b9f2..7881bd7e 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -25,6 +25,7 @@ import { PluginDetail } from '../components/plugins/PluginDetail' import { ComputerUseSettings } from './ComputerUseSettings' import { McpSettings } from './McpSettings' import { TerminalSettings } from './TerminalSettings' +import { DiagnosticsSettings } from './DiagnosticsSettings' import { useUIStore, type SettingsTab } from '../stores/uiStore' import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' import { useUpdateStore } from '../stores/updateStore' @@ -58,6 +59,7 @@ export function Settings() { setActiveTab('skills')} /> setActiveTab('plugins')} /> setActiveTab('computerUse')} /> + setActiveTab('diagnostics')} />
setActiveTab('about')} /> @@ -76,6 +78,7 @@ export function Settings() { {activeTab === 'skills' && } {activeTab === 'plugins' && } {activeTab === 'computerUse' && } + {activeTab === 'diagnostics' && } {activeTab === 'about' && }
diff --git a/desktop/src/stores/uiStore.ts b/desktop/src/stores/uiStore.ts index 1e0b91eb..0d9630b8 100644 --- a/desktop/src/stores/uiStore.ts +++ b/desktop/src/stores/uiStore.ts @@ -39,6 +39,7 @@ export type SettingsTab = | 'skills' | 'plugins' | 'computerUse' + | 'diagnostics' | 'about' type ActiveView = 'code' | 'scheduled' | 'terminal' | 'history' | 'settings' diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 4d354fe7..fe8f1975 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -185,7 +185,7 @@ describe('ConversationService', () => { expect(() => svc.onOutput('no-such-session', () => {})).not.toThrow() }) - it('should ignore stale process exits after a session restarts', () => { + it('should ignore stale process exits after a session restarts', async () => { const svc = new ConversationService() const oldProc = { pid: 1 } as any const newProc = { pid: 2 } as any @@ -203,10 +203,10 @@ describe('ConversationService', () => { pendingPermissionRequests: new Map(), }) - ;(svc as any).handleProcessExit('session-restart', oldProc, 143) + await (svc as any).handleProcessExit('session-restart', oldProc, 143) expect(svc.hasSession('session-restart')).toBe(true) - ;(svc as any).handleProcessExit('session-restart', newProc, 0) + await (svc as any).handleProcessExit('session-restart', newProc, 0) expect(svc.hasSession('session-restart')).toBe(false) }) @@ -445,6 +445,34 @@ describe('WebSocket Chat Integration', () => { } } + async function withMockStartupStdoutExit( + stdout: string, + exitDelayMs: number, + callback: () => Promise, + ): Promise { + const previousStdout = process.env.MOCK_SDK_STARTUP_STDOUT + const previousExitDelay = process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS + + process.env.MOCK_SDK_STARTUP_STDOUT = stdout + process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS = String(exitDelayMs) + + try { + return await callback() + } finally { + if (previousStdout === undefined) { + delete process.env.MOCK_SDK_STARTUP_STDOUT + } else { + process.env.MOCK_SDK_STARTUP_STDOUT = previousStdout + } + + if (previousExitDelay === undefined) { + delete process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS + } else { + process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS = previousExitDelay + } + } + } + async function runTurn(sessionId: string, content: string, allowError = false): Promise { const messages: any[] = [] const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) @@ -872,6 +900,46 @@ describe('WebSocket Chat Integration', () => { expect(secondTurn.some((m) => m.type === 'error')).toBe(false) }) + it('should keep a long desktop session alive in a /tmp project across engineering turns', async () => { + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-issue247-project-')) + + try { + await fs.writeFile( + path.join(projectDir, 'package.json'), + JSON.stringify({ name: 'issue-247-repro', type: 'module' }, null, 2), + ) + await fs.mkdir(path.join(projectDir, 'src'), { recursive: true }) + await fs.writeFile( + path.join(projectDir, 'src', 'index.ts'), + 'export function greet(name: string) { return `hello ${name}` }\n', + ) + + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: projectDir }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + + const prompts = [ + 'Inspect this TypeScript project and summarize what you see.', + 'Plan a small change to add a farewell helper.', + 'Implement the helper in src/index.ts.', + 'Review whether the exported functions are easy to test.', + 'Suggest the next regression test for this project.', + ] + + for (const prompt of prompts) { + const messages = await runTurn(sessionId, prompt) + expect(messages.some((m) => m.type === 'error')).toBe(false) + expect(messages.some((m) => m.type === 'message_complete')).toBe(true) + } + } finally { + await fs.rm(projectDir, { recursive: true, force: true }) + } + }, 20_000) + it('should clear a desktop session without sending /clear to the CLI turn loop', async () => { const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', @@ -951,6 +1019,25 @@ describe('WebSocket Chat Integration', () => { expect(error?.message).toContain('configuredProviders:') }) + it('should include CLI stdout diagnostics when startup exits before SDK messages', async () => { + const sessionId = `chat-startup-stdout-${crypto.randomUUID()}` + + const messages = await withMockStartupStdoutExit( + 'provider rejected request: invalid model id', + 25, + () => runTurn(sessionId, 'trigger startup stdout diagnostics', true), + ) + const error = messages.find((msg) => msg.type === 'error') + + expect(error).toMatchObject({ + code: 'CLI_START_FAILED', + }) + expect(error?.message).toContain( + 'CLI exited during startup (code 1): provider rejected request: invalid model id', + ) + expect(error?.message).toContain('Desktop service diagnostics:') + }, 10_000) + it('should prewarm the CLI before the first user turn and reuse that process', async () => { const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', diff --git a/src/server/__tests__/diagnostics-service.test.ts b/src/server/__tests__/diagnostics-service.test.ts new file mode 100644 index 00000000..2e4346db --- /dev/null +++ b/src/server/__tests__/diagnostics-service.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { gunzipSync } from 'node:zlib' +import { handleDiagnosticsApi } from '../api/diagnostics.js' +import { DiagnosticsService, diagnosticsService } from '../services/diagnosticsService.js' + +let tmpDir: string +let originalConfigDir: string | undefined + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-diagnostics-test-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir +}) + +afterEach(async () => { + diagnosticsService.restoreConsoleCaptureForTests() + if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalConfigDir + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +function makeRequest(method: string, urlStr: string): { req: Request; url: URL; segments: string[] } { + const url = new URL(urlStr, 'http://localhost:3456') + const req = new Request(url.toString(), { method }) + const segments = url.pathname.split('/').filter(Boolean) + return { req, url, segments } +} + +describe('DiagnosticsService', () => { + test('writes sanitized structured events and runtime error summaries', async () => { + const service = new DiagnosticsService() + await service.recordEvent({ + type: 'cli_start_failed', + severity: 'error', + sessionId: 'session-1', + summary: 'Authorization: Bearer sk-secret-token /Users/example/path', + details: { + apiKey: 'sk-secret', + url: 'https://api.example.com?api_key=secret-value', + nested: { message: `home=${os.homedir()}` }, + }, + }) + + const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'diagnostics.jsonl'), 'utf-8') + expect(raw).toContain('cli_start_failed') + expect(raw).toContain('[REDACTED]') + expect(raw).not.toContain('sk-secret') + expect(raw).not.toContain(os.homedir()) + + const runtime = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'runtime-errors.log'), 'utf-8') + expect(runtime).toContain('cli_start_failed') + expect(runtime).toContain('[REDACTED]') + expect(runtime).not.toContain('sk-secret-token') + }) + + test('exports a single diagnostics tarball without provider secrets', async () => { + const service = new DiagnosticsService() + await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) + await fs.writeFile( + path.join(tmpDir, 'cc-haha', 'providers.json'), + JSON.stringify({ + activeId: 'provider-1', + providers: [{ + id: 'provider-1', + name: 'Test Provider', + presetId: 'custom', + apiKey: 'sk-provider-secret', + baseUrl: 'https://api.example.com/anthropic', + apiFormat: 'anthropic', + models: { main: 'main-model', haiku: 'haiku-model', sonnet: 'sonnet-model', opus: 'opus-model' }, + }], + }), + 'utf-8', + ) + await service.recordEvent({ + type: 'provider_test_failed', + severity: 'warn', + sessionId: 'session-abc', + summary: 'provider failed with token=provider-secret', + details: { accessToken: 'provider-secret' }, + }) + + const bundle = await service.exportBundle() + expect(bundle.path).toEndWith('.tar.gz') + const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8') + expect(archiveText).toContain('README.txt') + expect(archiveText).toContain('providers-summary.json') + expect(archiveText).toContain('sessions-summary.json') + expect(archiveText).toContain('Test Provider') + expect(archiveText).toContain('api.example.com') + expect(archiveText).not.toContain('sk-provider-secret') + expect(archiveText).not.toContain('provider-secret') + }) +}) + +describe('diagnostics API', () => { + test('returns status, events, export path, and supports clearing logs', async () => { + const service = diagnosticsService + await service.recordEvent({ + type: 'api_unhandled_error', + severity: 'error', + summary: 'boom', + }) + + const statusReq = makeRequest('GET', '/api/diagnostics/status') + const statusRes = await handleDiagnosticsApi(statusReq.req, statusReq.url, statusReq.segments) + expect(statusRes.status).toBe(200) + const status = await statusRes.json() as { logDir: string; recentErrorCount: number } + expect(status.logDir).toContain(path.join('cc-haha', 'diagnostics')) + expect(status.recentErrorCount).toBe(1) + + const eventsReq = makeRequest('GET', '/api/diagnostics/events?limit=10') + const eventsRes = await handleDiagnosticsApi(eventsReq.req, eventsReq.url, eventsReq.segments) + expect(eventsRes.status).toBe(200) + const events = await eventsRes.json() as { events: Array<{ type: string }> } + expect(events.events[0].type).toBe('api_unhandled_error') + + const exportReq = makeRequest('POST', '/api/diagnostics/export') + const exportRes = await handleDiagnosticsApi(exportReq.req, exportReq.url, exportReq.segments) + expect(exportRes.status).toBe(200) + const exported = await exportRes.json() as { bundle: { path: string } } + await expect(fs.stat(exported.bundle.path)).resolves.toBeTruthy() + + const clearReq = makeRequest('DELETE', '/api/diagnostics') + const clearRes = await handleDiagnosticsApi(clearReq.req, clearReq.url, clearReq.segments) + expect(clearRes.status).toBe(200) + expect(await service.readRecentEvents()).toEqual([]) + }) +}) diff --git a/src/server/__tests__/fixtures/mock-sdk-cli.ts b/src/server/__tests__/fixtures/mock-sdk-cli.ts index 605aaaed..27c784e4 100644 --- a/src/server/__tests__/fixtures/mock-sdk-cli.ts +++ b/src/server/__tests__/fixtures/mock-sdk-cli.ts @@ -28,6 +28,8 @@ const initMode = process.env.MOCK_SDK_INIT_MODE || 'on_open' const streamDelayMs = Number(process.env.MOCK_SDK_STREAM_DELAY_MS || '0') const exitAfterOpenMs = Number(process.env.MOCK_SDK_EXIT_AFTER_OPEN_MS || '0') const exitAfterFirstUserMs = Number(process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS || '0') +const startupStdout = process.env.MOCK_SDK_STARTUP_STDOUT || '' +const exitBeforeSdkMs = Number(process.env.MOCK_SDK_EXIT_BEFORE_SDK_MS || '0') let initSent = false let firstUserExitScheduled = false @@ -36,6 +38,14 @@ if (!sdkUrl) { process.exit(1) } +if (startupStdout) { + console.log(startupStdout) +} + +if (exitBeforeSdkMs > 0) { + setTimeout(() => process.exit(1), exitBeforeSdkMs) +} + const ws = new WebSocket(sdkUrl) function sendInit() { diff --git a/src/server/api/diagnostics.ts b/src/server/api/diagnostics.ts new file mode 100644 index 00000000..ea32513c --- /dev/null +++ b/src/server/api/diagnostics.ts @@ -0,0 +1,50 @@ +/** + * Diagnostics REST API + * + * GET /api/diagnostics/status — log directory, retention and counters + * GET /api/diagnostics/events — recent sanitized diagnostic events + * POST /api/diagnostics/export — write a sanitized tar.gz bundle + * POST /api/diagnostics/open-log-dir — open the diagnostics directory + * DELETE /api/diagnostics — clear diagnostics files + */ + +import { diagnosticsService } from '../services/diagnosticsService.js' +import { ApiError, errorResponse } from '../middleware/errorHandler.js' + +export async function handleDiagnosticsApi( + req: Request, + url: URL, + segments: string[], +): Promise { + try { + const action = segments[2] + + if (!action && req.method === 'DELETE') { + await diagnosticsService.clear() + return Response.json({ ok: true }) + } + + if (action === 'status' && req.method === 'GET') { + return Response.json(await diagnosticsService.getStatus()) + } + + if (action === 'events' && req.method === 'GET') { + const limit = Number.parseInt(url.searchParams.get('limit') || '100', 10) + const events = await diagnosticsService.readRecentEvents(Number.isFinite(limit) ? limit : 100) + return Response.json({ events }) + } + + if (action === 'export' && req.method === 'POST') { + return Response.json({ bundle: await diagnosticsService.exportBundle() }) + } + + if (action === 'open-log-dir' && req.method === 'POST') { + await diagnosticsService.openLogDir() + return Response.json({ ok: true }) + } + + throw new ApiError(404, `Unknown diagnostics endpoint: ${action ?? '(root)'}`, 'NOT_FOUND') + } catch (error) { + return errorResponse(error) + } +} diff --git a/src/server/api/providers.ts b/src/server/api/providers.ts index 2c574f5b..f2c94147 100644 --- a/src/server/api/providers.ts +++ b/src/server/api/providers.ts @@ -24,6 +24,7 @@ import { TestProviderSchema, } from '../types/provider.js' import { ApiError, errorResponse } from '../middleware/errorHandler.js' +import { diagnosticsService } from '../services/diagnosticsService.js' const providerService = new ProviderService() @@ -99,6 +100,21 @@ export async function handleProvidersApi( if (body && typeof body === 'object') overrides = body as typeof overrides } catch { /* no body is fine — uses saved values */ } const result = await providerService.testProvider(id, overrides) + if (!result.connectivity.success || result.proxy?.success === false) { + void diagnosticsService.recordEvent({ + type: 'provider_test_failed', + severity: 'warn', + summary: result.connectivity.error || result.proxy?.error || 'Provider test failed', + details: { + providerId: id, + httpStatus: result.connectivity.httpStatus ?? result.proxy?.httpStatus, + apiFormat: overrides?.apiFormat, + modelId: overrides?.modelId, + connectivity: result.connectivity, + proxy: result.proxy, + }, + }) + } return Response.json({ result }) } @@ -150,8 +166,31 @@ async function handleTestUnsaved(req: Request): Promise { try { const input = TestProviderSchema.parse(body) const result = await providerService.testProviderConfig(input) + if (!result.connectivity.success || result.proxy?.success === false) { + void diagnosticsService.recordEvent({ + type: 'provider_test_failed', + severity: 'warn', + summary: result.connectivity.error || result.proxy?.error || 'Provider test failed', + details: { + providerId: null, + baseUrl: input.baseUrl, + apiFormat: input.apiFormat, + modelId: input.modelId, + connectivity: result.connectivity, + proxy: result.proxy, + }, + }) + } return Response.json({ result }) } catch (err) { + if (!(err instanceof z.ZodError)) { + void diagnosticsService.recordEvent({ + type: 'provider_test_failed', + severity: 'warn', + summary: err instanceof Error ? err.message : String(err), + details: { providerId: null, error: err }, + }) + } if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; ')) throw err } diff --git a/src/server/index.ts b/src/server/index.ts index 98c9b69d..0de061d6 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -16,6 +16,7 @@ import { ProviderService } from './services/providerService.js' import { handleHahaOAuthCallback } from './api/haha-oauth.js' import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js' import { enableConfigs } from '../utils/config.js' +import { diagnosticsService } from './services/diagnosticsService.js' function readArgValue(flag: string): string | undefined { const args = process.argv.slice(2) @@ -48,6 +49,7 @@ const HOST = SERVER_OPTIONS.host export function startServer(port = PORT, host = HOST) { enableConfigs() + diagnosticsService.installConsoleCapture() ProviderService.setServerPort(port) const localConnectHost = host === '0.0.0.0' || host === '127.0.0.1' || host === 'localhost' @@ -163,6 +165,12 @@ export function startServer(port = PORT, host = HOST) { headers, }) } catch (error) { + void diagnosticsService.recordEvent({ + type: 'api_request_failed', + severity: 'error', + summary: error instanceof Error ? error.message : String(error), + details: { path: url.pathname, method: req.method, error }, + }) console.error('[Server] API error:', error) return Response.json( { error: 'Internal server error' }, @@ -194,6 +202,12 @@ export function startServer(port = PORT, host = HOST) { headers, }) } catch (error) { + void diagnosticsService.recordEvent({ + type: 'proxy_request_failed', + severity: 'error', + summary: error instanceof Error ? error.message : String(error), + details: { path: url.pathname, method: req.method, error }, + }) console.error('[Server] Proxy error:', error) return Response.json( { type: 'error', error: { type: 'api_error', message: 'Internal proxy error' } }, diff --git a/src/server/middleware/errorHandler.ts b/src/server/middleware/errorHandler.ts index 485ce66e..e03c4af7 100644 --- a/src/server/middleware/errorHandler.ts +++ b/src/server/middleware/errorHandler.ts @@ -2,6 +2,8 @@ * Unified error handling utilities */ +import { diagnosticsService } from '../services/diagnosticsService.js' + export class ApiError extends Error { constructor( public statusCode: number, @@ -37,6 +39,12 @@ export function errorResponse(error: unknown): Response { ) } + void diagnosticsService.recordEvent({ + type: 'api_unhandled_error', + severity: 'error', + summary: error instanceof Error ? error.message : String(error), + details: error, + }) console.error('[Server] Unexpected error:', error) return Response.json( { error: 'INTERNAL_ERROR', message: 'An unexpected error occurred' }, diff --git a/src/server/router.ts b/src/server/router.ts index 87853152..73c5bf7e 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -19,6 +19,7 @@ import { handleSkillsApi } from './api/skills.js' import { handleComputerUseApi } from './api/computer-use.js' import { handleHahaOAuthApi } from './api/haha-oauth.js' import { handleMcpApi } from './api/mcp.js' +import { handleDiagnosticsApi } from './api/diagnostics.js' export async function handleApiRequest(req: Request, url: URL): Promise { const path = url.pathname @@ -87,6 +88,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise sdkMessages: any[] initMessage: any | null pendingPermissionRequests: Map< @@ -168,10 +173,23 @@ export class ConversationService { cwd: workDir, env: childEnv, stdin: 'pipe', - stdout: 'ignore', // CLI communicates via SDK WebSocket, not stdout + stdout: 'pipe', stderr: 'pipe', }) } catch (spawnErr) { + void diagnosticsService.recordEvent({ + type: 'cli_spawn_failed', + severity: 'error', + sessionId, + summary: spawnErr instanceof Error ? spawnErr.message : String(spawnErr), + details: { + workDir, + permissionMode: options?.permissionMode || 'default', + providerId: options?.providerId ?? null, + model: options?.model ?? null, + error: spawnErr, + }, + }) throw new ConversationStartupError( `Failed to spawn CLI in ${workDir}: ${ spawnErr instanceof Error ? spawnErr.message : String(spawnErr) @@ -188,17 +206,24 @@ export class ConversationService { sdkToken: this.getSdkTokenFromUrl(sdkUrl), sdkSocket: null, pendingOutbound: [], + startupPending: true, + startupExitCode: null, + stdoutLines: [], stderrLines: [], + outputDrain: Promise.resolve(), sdkMessages: [], initMessage: null, pendingPermissionRequests: new Map(), } this.sessions.set(sessionId, session) - this.readErrorStream(sessionId, proc) + session.outputDrain = Promise.all([ + this.readProcessOutputStream(sessionId, proc.stdout, 'stdout'), + this.readProcessOutputStream(sessionId, proc.stderr, 'stderr'), + ]).then(() => undefined) proc.exited.then((code) => { - this.handleProcessExit(sessionId, proc, code) + void this.handleProcessExit(sessionId, proc, code) }) const STARTUP_GRACE_MS = 3000 @@ -209,8 +234,10 @@ export class ConversationService { ), ]) - if (earlyExitCode !== null) { - const startupError = this.buildStartupError(sessionId, earlyExitCode) + const startupExitCode = earlyExitCode ?? session.startupExitCode + if (startupExitCode !== null) { + await this.waitForProcessOutputDrain(session) + const startupError = this.buildStartupError(sessionId, startupExitCode) this.sessions.delete(sessionId) if (this.clearStaleLock(sessionId)) { @@ -221,11 +248,30 @@ export class ConversationService { } console.error( - `[ConversationService] CLI exited with code ${earlyExitCode} for ${sessionId}: ${startupError.message}`, + `[ConversationService] CLI exited with code ${startupExitCode} for ${sessionId}: ${startupError.message}`, ) + void diagnosticsService.recordEvent({ + type: 'cli_start_failed', + severity: 'error', + sessionId, + summary: startupError.message, + details: { + code: startupError.code, + exitCode: startupExitCode, + retryable: startupError.retryable, + workDir, + permissionMode: options?.permissionMode || 'default', + providerId: options?.providerId ?? null, + model: options?.model ?? null, + capturedOutput: this.buildCapturedProcessOutputDetail(session), + sdkMessages: this.summarizeSdkMessages(session.sdkMessages), + }, + }) throw startupError } + session.startupPending = false + if (shouldReplacePlaceholder || !launchInfo) { await sessionService.appendSessionMetadata(sessionId, { workDir, @@ -503,13 +549,14 @@ export class ConversationService { return Array.from(this.sessions.keys()) } - private async readErrorStream( + private async readProcessOutputStream( sessionId: string, - proc: ReturnType, + stream: ReadableStream | null | undefined, + streamName: 'stdout' | 'stderr', ): Promise { - if (!proc.stderr) return + if (!stream) return - const reader = (proc.stderr as ReadableStream).getReader() + const reader = stream.getReader() const decoder = new TextDecoder() try { @@ -526,20 +573,38 @@ export class ConversationService { .split('\n') .map((entry) => entry.trim()) .filter(Boolean)) { - session.stderrLines.push(line) - if (session.stderrLines.length > 20) { - session.stderrLines.splice(0, 10) + const lines = + streamName === 'stderr' ? session.stderrLines : session.stdoutLines + lines.push(this.redactProcessOutput(line)) + if (lines.length > 20) { + lines.splice(0, 10) } } } - console.error(`[CLI:${sessionId}] ${text.trim()}`) + const logLine = this.redactProcessOutput(text.trim()) + if (streamName === 'stderr') { + console.error(`[CLI:${sessionId}:stderr] ${logLine}`) + } else { + console.log(`[CLI:${sessionId}:stdout] ${logLine}`) + } } } catch { - // stderr read failures should not kill the session + // Process output read failures should not kill the session. } } + private async waitForProcessOutputDrain( + session: SessionProcess, + timeoutMs = 250, + ): Promise { + const outputDrain = session.outputDrain ?? Promise.resolve() + await Promise.race([ + outputDrain.catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]) + } + private sendSdkMessage( sessionId: string, payload: Record, @@ -556,18 +621,36 @@ export class ConversationService { return true } - private handleProcessExit( + private async handleProcessExit( sessionId: string, proc: SessionProcess['proc'], code: number, - ): void { + ): Promise { console.log( `[ConversationService] CLI process for ${sessionId} exited with code ${code}`, ) const activeSession = this.sessions.get(sessionId) if (activeSession?.proc === proc) { + if (activeSession.startupPending) { + activeSession.startupExitCode = code + return + } + await this.waitForProcessOutputDrain(activeSession) const exitError = this.buildRuntimeExitMessage(sessionId, code) + void diagnosticsService.recordEvent({ + type: 'cli_runtime_exit', + severity: 'error', + sessionId, + summary: exitError, + details: { + exitCode: code, + workDir: activeSession.workDir, + permissionMode: activeSession.permissionMode, + capturedOutput: this.buildCapturedProcessOutputDetail(activeSession), + sdkMessages: this.summarizeSdkMessages(activeSession.sdkMessages), + }, + }) for (const cb of activeSession.outputCallbacks) { cb({ type: 'result', @@ -854,7 +937,7 @@ export class ConversationService { exitCode: number, ): ConversationStartupError { const session = this.sessions.get(sessionId) - const stderrText = session?.stderrLines.join('\n') ?? '' + const capturedOutput = this.buildCapturedProcessOutputDetail(session) const recentMessages = session?.sdkMessages ?? [] const resultMessage = [...recentMessages] .reverse() @@ -865,7 +948,7 @@ export class ConversationService { const detail = this.extractStartupDetail(resultMessage) || this.extractStartupDetail(authStatus) || - stderrText + capturedOutput if ( /(not logged in|run \/login|sign in again|login required|unauthenticated|logged_out)/i.test( @@ -890,7 +973,7 @@ export class ConversationService { return new ConversationStartupError( normalizedDetail ? `CLI exited during startup (code ${exitCode}): ${normalizedDetail}` - : `CLI exited during startup with code ${exitCode}.`, + : `CLI exited during startup with code ${exitCode}; no CLI stderr/stdout or SDK error payload was captured before exit.`, 'CLI_START_FAILED', true, ) @@ -898,7 +981,7 @@ export class ConversationService { private buildRuntimeExitMessage(sessionId: string, exitCode: number): string { const session = this.sessions.get(sessionId) - const stderrText = session?.stderrLines.join('\n').trim() ?? '' + const capturedOutput = this.buildCapturedProcessOutputDetail(session) const recentMessages = session?.sdkMessages ?? [] const resultMessage = [...recentMessages] .reverse() @@ -909,11 +992,33 @@ export class ConversationService { const detail = this.extractStartupDetail(resultMessage) || this.extractStartupDetail(authStatus) || - stderrText + capturedOutput return detail ? `CLI process exited unexpectedly (code ${exitCode}): ${detail}` - : `CLI process exited unexpectedly with code ${exitCode}.` + : `CLI process exited unexpectedly with code ${exitCode}; no CLI stderr/stdout or SDK error payload was captured before exit.` + } + + private buildCapturedProcessOutputDetail( + session: SessionProcess | undefined, + ): string { + if (!session) return '' + + const stderrText = (session.stderrLines ?? []).join('\n').trim() + const stdoutText = (session.stdoutLines ?? []).join('\n').trim() + + if (stderrText && stdoutText) { + return `stderr:\n${stderrText}\nstdout:\n${stdoutText}` + } + + return stderrText || stdoutText + } + + private redactProcessOutput(line: string): string { + return line + .replace(/(ANTHROPIC_(?:API_KEY|AUTH_TOKEN)\s*[:=]\s*)[^\s,;]+/gi, '$1[REDACTED]') + .replace(/((?:api[_-]?key|auth[_-]?token|access[_-]?token)\s*[:=]\s*)[^\s,;]+/gi, '$1[REDACTED]') + .replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/gi, '$1[REDACTED]') } private extractStartupDetail(message: any): string { @@ -932,6 +1037,22 @@ export class ConversationService { return '' } + private summarizeSdkMessages(messages: any[]): unknown[] { + return messages.slice(-10).map((message) => { + if (!message || typeof message !== 'object') { + return message + } + return { + type: message.type, + subtype: message.subtype, + is_error: message.is_error, + status: typeof message.status === 'string' ? message.status : undefined, + result: typeof message.result === 'string' ? message.result : undefined, + message: typeof message.message === 'string' ? message.message : undefined, + } + }) + } + private buildUserContent( content: string, sessionId: string, diff --git a/src/server/services/diagnosticsService.ts b/src/server/services/diagnosticsService.ts new file mode 100644 index 00000000..b57189f8 --- /dev/null +++ b/src/server/services/diagnosticsService.ts @@ -0,0 +1,510 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { gzipSync } from 'node:zlib' +import type { Dirent } from 'node:fs' + +export type DiagnosticSeverity = 'debug' | 'info' | 'warn' | 'error' + +export type DiagnosticEventInput = { + type: string + severity?: DiagnosticSeverity + summary: string + sessionId?: string + details?: unknown +} + +export type DiagnosticEvent = { + id: string + timestamp: string + type: string + severity: DiagnosticSeverity + summary: string + sessionId?: string + details?: unknown +} + +export type DiagnosticsStatus = { + logDir: string + diagnosticsPath: string + runtimeErrorsPath: string + exportDir: string + retentionDays: number + maxBytes: number + totalBytes: number + eventCount: number + recentErrorCount: number + lastEventAt: string | null +} + +export type DiagnosticsExportResult = { + path: string + fileName: string + bytes: number +} + +const RETENTION_DAYS = 7 +const MAX_BYTES = 50 * 1024 * 1024 +const MAX_STRING_LENGTH = 4096 +const MAX_ARRAY_ITEMS = 40 +const MAX_OBJECT_KEYS = 80 +const MAX_EVENTS_IN_EXPORT = 5000 +const SENSITIVE_KEY_RE = /(api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|\btoken\b|secret|password|authorization|cookie|oauth)/i + +export class DiagnosticsService { + private consoleCaptureInstalled = false + private originalConsoleError: typeof console.error | null = null + private originalConsoleWarn: typeof console.warn | null = null + + getLogDir(): string { + return path.join(this.getConfigDir(), 'cc-haha', 'diagnostics') + } + + getDiagnosticsPath(): string { + return path.join(this.getLogDir(), 'diagnostics.jsonl') + } + + getRuntimeErrorsPath(): string { + return path.join(this.getLogDir(), 'runtime-errors.log') + } + + getExportDir(): string { + return path.join(this.getLogDir(), 'exports') + } + + async recordEvent(input: DiagnosticEventInput): Promise { + const event: DiagnosticEvent = { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + type: input.type, + severity: input.severity ?? 'error', + summary: this.sanitizeString(input.summary), + ...(input.sessionId ? { sessionId: this.sanitizeString(input.sessionId, 256) } : {}), + ...(input.details !== undefined ? { details: this.sanitizeValue(input.details) } : {}), + } + + try { + await this.ensureLogDir() + await fs.appendFile(this.getDiagnosticsPath(), JSON.stringify(event) + '\n', 'utf-8') + if (event.severity === 'warn' || event.severity === 'error') { + await fs.appendFile(this.getRuntimeErrorsPath(), this.formatRuntimeLogLine(event), 'utf-8') + } + await this.enforceRetention().catch(() => {}) + } catch { + // Diagnostics must never break the product path. + } + } + + installConsoleCapture(): void { + if (this.consoleCaptureInstalled) return + this.consoleCaptureInstalled = true + this.originalConsoleError = console.error.bind(console) + this.originalConsoleWarn = console.warn.bind(console) + + console.error = (...args: unknown[]) => { + this.originalConsoleError?.(...args) + void this.recordEvent({ + type: 'console_error', + severity: 'error', + summary: this.formatConsoleArgs(args), + }) + } + + console.warn = (...args: unknown[]) => { + this.originalConsoleWarn?.(...args) + void this.recordEvent({ + type: 'console_warn', + severity: 'warn', + summary: this.formatConsoleArgs(args), + }) + } + } + + restoreConsoleCaptureForTests(): void { + if (this.originalConsoleError) console.error = this.originalConsoleError + if (this.originalConsoleWarn) console.warn = this.originalConsoleWarn + this.consoleCaptureInstalled = false + this.originalConsoleError = null + this.originalConsoleWarn = null + } + + async getStatus(): Promise { + await this.ensureLogDir() + const events = await this.readRecentEvents(500) + const totalBytes = await this.getDirectorySize(this.getLogDir()) + const cutoff = Date.now() - 24 * 60 * 60 * 1000 + return { + logDir: this.getLogDir(), + diagnosticsPath: this.getDiagnosticsPath(), + runtimeErrorsPath: this.getRuntimeErrorsPath(), + exportDir: this.getExportDir(), + retentionDays: RETENTION_DAYS, + maxBytes: MAX_BYTES, + totalBytes, + eventCount: events.length, + recentErrorCount: events.filter((event) => + (event.severity === 'error' || event.severity === 'warn') && + Date.parse(event.timestamp) >= cutoff + ).length, + lastEventAt: events[0]?.timestamp ?? null, + } + } + + async readRecentEvents(limit = 100): Promise { + const boundedLimit = Math.max(1, Math.min(limit, 1000)) + let raw = '' + try { + raw = await fs.readFile(this.getDiagnosticsPath(), 'utf-8') + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw err + } + + return raw + .split('\n') + .filter(Boolean) + .slice(-boundedLimit) + .map((line) => { + try { + return JSON.parse(line) as DiagnosticEvent + } catch { + return null + } + }) + .filter((event): event is DiagnosticEvent => event !== null) + .reverse() + } + + async exportBundle(): Promise { + await this.ensureLogDir() + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const fileName = `cc-haha-diagnostics-${timestamp}.tar.gz` + const outPath = path.join(this.getExportDir(), fileName) + const events = await this.readRecentEvents(MAX_EVENTS_IN_EXPORT) + const files = [ + { + name: 'README.txt', + content: this.buildReadme(), + }, + { + name: 'app-info.json', + content: JSON.stringify(this.buildAppInfo(), null, 2) + '\n', + }, + { + name: 'diagnostics.jsonl', + content: events.map((event) => JSON.stringify(this.sanitizeValue(event))).join('\n') + (events.length ? '\n' : ''), + }, + { + name: 'runtime-errors.log', + content: await this.readSanitizedTextFile(this.getRuntimeErrorsPath()), + }, + { + name: 'providers-summary.json', + content: JSON.stringify(await this.buildProvidersSummary(), null, 2) + '\n', + }, + { + name: 'sessions-summary.json', + content: JSON.stringify(this.buildSessionsSummary(events), null, 2) + '\n', + }, + ] + + const archive = this.createTarGz(files) + await fs.mkdir(this.getExportDir(), { recursive: true }) + await fs.writeFile(outPath, archive) + return { path: outPath, fileName, bytes: archive.byteLength } + } + + async openLogDir(): Promise { + await this.ensureLogDir() + const dir = this.getLogDir() + if (process.platform === 'darwin') { + Bun.spawn(['open', dir], { stdout: 'ignore', stderr: 'ignore' }) + return + } + if (process.platform === 'win32') { + Bun.spawn(['cmd', '/c', 'start', '', dir], { stdout: 'ignore', stderr: 'ignore' }) + return + } + Bun.spawn(['xdg-open', dir], { stdout: 'ignore', stderr: 'ignore' }) + } + + async clear(): Promise { + await fs.rm(this.getLogDir(), { recursive: true, force: true }) + await this.ensureLogDir() + } + + sanitizeValue(value: unknown, depth = 0): unknown { + if (depth > 6) return '[TRUNCATED_DEPTH]' + if (value === null || value === undefined) return value + if (typeof value === 'string') return this.sanitizeString(value) + if (typeof value === 'number' || typeof value === 'boolean') return value + if (typeof value === 'bigint') return value.toString() + if (value instanceof Error) { + return { + name: value.name, + message: this.sanitizeString(value.message), + stack: value.stack ? this.sanitizeString(value.stack) : undefined, + } + } + if (Array.isArray(value)) { + return value.slice(0, MAX_ARRAY_ITEMS).map((entry) => this.sanitizeValue(entry, depth + 1)) + } + if (typeof value === 'object') { + const result: Record = {} + let count = 0 + for (const [key, entry] of Object.entries(value as Record)) { + if (count >= MAX_OBJECT_KEYS) { + result.__truncatedKeys = true + break + } + count += 1 + result[key] = SENSITIVE_KEY_RE.test(key) + ? '[REDACTED]' + : this.sanitizeValue(entry, depth + 1) + } + return result + } + return String(value) + } + + sanitizeString(value: string, maxLength = MAX_STRING_LENGTH): string { + let sanitized = value + .replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/gi, '$1[REDACTED]') + .replace(/((?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|token|secret|password)\s*[:=]\s*)[^\s,;"'}]+/gi, '$1[REDACTED]') + .replace(/(ANTHROPIC_(?:API_KEY|AUTH_TOKEN)\s*[:=]\s*)[^\s,;"'}]+/gi, '$1[REDACTED]') + .replace(/([?&](?:api[_-]?key|token|auth|access_token|refresh_token|key)=)[^&\s]+/gi, '$1[REDACTED]') + + const home = os.homedir() + if (home && sanitized.includes(home)) { + sanitized = sanitized.split(home).join('~') + } + + if (sanitized.length > maxLength) { + return `${sanitized.slice(0, maxLength)}...[TRUNCATED ${sanitized.length - maxLength} chars]` + } + return sanitized + } + + private async ensureLogDir(): Promise { + await fs.mkdir(this.getExportDir(), { recursive: true }) + } + + private getConfigDir(): string { + return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') + } + + private formatConsoleArgs(args: unknown[]): string { + return this.sanitizeString(args.map((arg) => { + if (arg instanceof Error) return `${arg.name}: ${arg.message}\n${arg.stack ?? ''}` + if (typeof arg === 'string') return arg + try { + return JSON.stringify(this.sanitizeValue(arg)) + } catch { + return String(arg) + } + }).join(' ')) + } + + private formatRuntimeLogLine(event: DiagnosticEvent): string { + return `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}: ${event.summary}\n` + } + + private buildReadme(): string { + return [ + 'cc-haha diagnostics bundle', + '', + 'This bundle is generated by the desktop app for debugging server and CLI startup/runtime failures.', + 'It intentionally excludes chat prompts, assistant replies, file contents, attachments, full environment variables, API keys, bearer tokens, cookies, and OAuth tokens.', + 'Paths under the current home directory are normalized to "~". Long fields are truncated.', + '', + 'Files:', + '- app-info.json: runtime and platform summary.', + '- diagnostics.jsonl: sanitized structured diagnostic events.', + '- runtime-errors.log: sanitized warning/error summaries.', + '- providers-summary.json: provider count, active id, base URL host, model ids, and API format without API keys.', + '- sessions-summary.json: session ids observed in diagnostic events, without transcript content.', + '', + ].join('\n') + } + + private buildAppInfo(): Record { + return this.sanitizeValue({ + appVersion: process.env.APP_VERSION || '999.0.0-local', + platform: process.platform, + arch: process.arch, + node: process.version, + bun: typeof Bun !== 'undefined' ? Bun.version : null, + cwd: process.cwd(), + uptimeSeconds: Math.round(process.uptime()), + generatedAt: new Date().toISOString(), + }) as Record + } + + private async buildProvidersSummary(): Promise> { + const providerPath = path.join(this.getConfigDir(), 'cc-haha', 'providers.json') + try { + const raw = await fs.readFile(providerPath, 'utf-8') + const parsed = JSON.parse(raw) as { + activeId?: string | null + providers?: Array> + } + return { + activeId: parsed.activeId ?? null, + count: Array.isArray(parsed.providers) ? parsed.providers.length : 0, + providers: (parsed.providers ?? []).map((provider) => ({ + id: provider.id, + name: provider.name, + presetId: provider.presetId, + apiFormat: provider.apiFormat, + baseUrl: this.summarizeUrl(typeof provider.baseUrl === 'string' ? provider.baseUrl : ''), + models: provider.models, + })), + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return { activeId: null, count: 0, providers: [] } + } + return { error: this.sanitizeString(err instanceof Error ? err.message : String(err)) } + } + } + + private summarizeUrl(value: string): Record | null { + if (!value.trim()) return null + try { + const url = new URL(value) + return { protocol: url.protocol, host: url.host, pathname: url.pathname } + } catch { + return { value: this.sanitizeString(value, 512) } + } + } + + private buildSessionsSummary(events: DiagnosticEvent[]): Record { + const sessions = new Map }>() + for (const event of events) { + if (!event.sessionId) continue + const current = sessions.get(event.sessionId) ?? { + eventCount: 0, + lastEventAt: event.timestamp, + severities: new Set(), + } + current.eventCount += 1 + current.lastEventAt = current.lastEventAt > event.timestamp ? current.lastEventAt : event.timestamp + current.severities.add(event.severity) + sessions.set(event.sessionId, current) + } + return { + count: sessions.size, + sessions: [...sessions.entries()].map(([sessionId, info]) => ({ + sessionId, + eventCount: info.eventCount, + lastEventAt: info.lastEventAt, + severities: [...info.severities], + })), + transcriptContentIncluded: false, + } + } + + private async readSanitizedTextFile(filePath: string): Promise { + try { + return this.sanitizeString(await fs.readFile(filePath, 'utf-8'), 2 * MAX_STRING_LENGTH) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return '' + throw err + } + } + + private async enforceRetention(): Promise { + const dir = this.getLogDir() + const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000 + const files = await this.listFiles(dir) + + for (const file of files) { + if (file.mtimeMs < cutoff) { + await fs.rm(file.path, { force: true }) + } + } + + const remaining = (await this.listFiles(dir)).sort((a, b) => a.mtimeMs - b.mtimeMs) + let total = remaining.reduce((sum, file) => sum + file.size, 0) + for (const file of remaining) { + if (total <= MAX_BYTES) break + await fs.rm(file.path, { force: true }) + total -= file.size + } + } + + private async getDirectorySize(dir: string): Promise { + return (await this.listFiles(dir)).reduce((sum, file) => sum + file.size, 0) + } + + private async listFiles(dir: string): Promise> { + const results: Array<{ path: string; size: number; mtimeMs: number }> = [] + let entries: Dirent[] + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw err + } + + for (const entry of entries) { + const filePath = path.join(dir, entry.name) + if (entry.isDirectory()) { + results.push(...await this.listFiles(filePath)) + continue + } + if (!entry.isFile()) continue + const stat = await fs.stat(filePath) + results.push({ path: filePath, size: stat.size, mtimeMs: stat.mtimeMs }) + } + return results + } + + private createTarGz(files: Array<{ name: string; content: string }>): Buffer { + const chunks: Buffer[] = [] + const mtime = Math.floor(Date.now() / 1000) + for (const file of files) { + const body = Buffer.from(file.content, 'utf-8') + chunks.push(this.createTarHeader(file.name, body.byteLength, mtime)) + chunks.push(body) + const padding = (512 - (body.byteLength % 512)) % 512 + if (padding > 0) chunks.push(Buffer.alloc(padding)) + } + chunks.push(Buffer.alloc(1024)) + return gzipSync(Buffer.concat(chunks)) + } + + private createTarHeader(name: string, size: number, mtime: number): Buffer { + const header = Buffer.alloc(512) + this.writeTarString(header, 0, 100, name) + this.writeTarString(header, 100, 8, '0000644') + this.writeTarString(header, 108, 8, '0000000') + this.writeTarString(header, 116, 8, '0000000') + this.writeTarOctal(header, 124, 12, size) + this.writeTarOctal(header, 136, 12, mtime) + header.fill(0x20, 148, 156) + header[156] = '0'.charCodeAt(0) + this.writeTarString(header, 257, 6, 'ustar') + this.writeTarString(header, 263, 2, '00') + + let checksum = 0 + for (const byte of header) checksum += byte + const checksumValue = checksum.toString(8).padStart(6, '0') + header.write(checksumValue.slice(-6), 148, 6, 'ascii') + header[154] = 0 + header[155] = 0x20 + return header + } + + private writeTarString(header: Buffer, offset: number, length: number, value: string): void { + header.write(value.slice(0, length), offset, length, 'utf-8') + } + + private writeTarOctal(header: Buffer, offset: number, length: number, value: number): void { + const encoded = value.toString(8).padStart(length - 1, '0') + header.write(encoded.slice(-length + 1), offset, length - 1, 'ascii') + header[offset + length - 1] = 0 + } +} + +export const diagnosticsService = new DiagnosticsService() diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 6c1f623e..980be3d6 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -17,6 +17,7 @@ import { computerUseApprovalService } from '../services/computerUseApprovalServi import { sessionService } from '../services/sessionService.js' import { SettingsService } from '../services/settingsService.js' import { ProviderService } from '../services/providerService.js' +import { diagnosticsService } from '../services/diagnosticsService.js' import { deriveTitle, generateTitle, saveAiTitle } from '../services/titleService.js' import { parseSlashCommand } from '../../utils/slashCommandParsing.js' import { @@ -134,6 +135,13 @@ export const handleWebSocket = { switch (message.type) { case 'user_message': handleUserMessage(ws, message).catch((err) => { + void diagnosticsService.recordEvent({ + type: 'ws_user_message_failed', + severity: 'error', + sessionId: ws.data.sessionId, + summary: err instanceof Error ? err.message : String(err), + details: err, + }) console.error(`[WS] Unhandled error in handleUserMessage:`, err) }) break @@ -245,6 +253,13 @@ async function handleUserMessage( await pendingRuntimeTransition } catch (err) { const errMsg = err instanceof Error ? err.message : String(err) + void diagnosticsService.recordEvent({ + type: 'runtime_transition_failed', + severity: 'error', + sessionId, + summary: errMsg, + details: err, + }) console.error(`[WS] Runtime transition failed before handling user message for ${sessionId}: ${errMsg}`) sendMessage(ws, { type: 'error', @@ -514,6 +529,13 @@ async function restartSessionWithPermissionMode( console.log(`[WS] Restarted CLI for ${sessionId} with permission mode: ${mode}`) } catch (err) { const errMsg = err instanceof Error ? err.message : String(err) + void diagnosticsService.recordEvent({ + type: 'permission_restart_failed', + severity: 'error', + sessionId, + summary: errMsg, + details: { mode, error: err }, + }) console.error(`[WS] Failed to restart CLI for ${sessionId}: ${errMsg}`) sendMessage(ws, { type: 'error', @@ -551,6 +573,13 @@ async function restartSessionWithRuntimeConfig( console.log(`[WS] Restarted CLI for ${sessionId} with runtime override`) } catch (err) { const errMsg = err instanceof Error ? err.message : String(err) + void diagnosticsService.recordEvent({ + type: 'runtime_config_restart_failed', + severity: 'error', + sessionId, + summary: errMsg, + details: { runtimeOverride: runtimeOverrides.get(sessionId), error: err }, + }) console.error(`[WS] Failed to restart CLI for ${sessionId} after runtime override: ${errMsg}`) sendMessage(ws, { type: 'error',