From 5871188084a480395482b89f4d42241378c5dcf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 12 Jul 2026 00:25:54 +0800 Subject: [PATCH] fix(diagnostics): harden diagnostics and recovery flows Make diagnostics evidence bounded, share-safe, and resilient to concurrent writers and corrupt segments. Improve Doctor repair safeguards, Electron runtime recovery, Settings visibility, accessibility, and issue reporting. --- .github/ISSUE_TEMPLATE/bug_report.md | 13 +- desktop/electron/main.ts | 2 + .../electron/services/serverRuntime.test.ts | 334 +++++++++ desktop/electron/services/serverRuntime.ts | 264 ++++++- .../electron/services/sidecarManager.test.ts | 114 ++- desktop/electron/services/sidecarManager.ts | 122 +++- .../__tests__/diagnosticsSettings.test.tsx | 571 ++++++++++++++- desktop/src/api/diagnostics.ts | 4 + desktop/src/api/doctor.ts | 44 +- desktop/src/components/doctor/DoctorPanel.tsx | 225 ++++-- desktop/src/components/shared/Toast.test.tsx | 46 ++ desktop/src/components/shared/Toast.tsx | 7 + desktop/src/i18n/locales/en.ts | 49 +- desktop/src/i18n/locales/jp.ts | 49 +- desktop/src/i18n/locales/kr.ts | 49 +- desktop/src/i18n/locales/zh-TW.ts | 49 +- desktop/src/i18n/locales/zh.ts | 49 +- desktop/src/lib/doctorRepair.test.ts | 24 +- desktop/src/lib/doctorRepair.ts | 30 +- desktop/src/pages/DiagnosticsSettings.tsx | 90 ++- desktop/src/pages/Settings.tsx | 3 +- .../__tests__/conversation-service.test.ts | 29 + .../__tests__/diagnostics-service.test.ts | 654 +++++++++++++++++- src/server/__tests__/doctor-service.test.ts | 65 ++ src/server/api/diagnostics.ts | 10 +- src/server/services/conversationService.ts | 45 +- src/server/services/diagnosticsService.ts | 540 +++++++++++++-- src/server/services/diagnosticsShare.test.ts | 177 +++++ src/server/services/diagnosticsShare.ts | 263 +++++++ src/server/services/doctorService.ts | 30 +- src/utils/diagLogs.test.ts | 38 + src/utils/diagLogs.ts | 33 +- 32 files changed, 3695 insertions(+), 327 deletions(-) create mode 100644 desktop/electron/services/serverRuntime.test.ts create mode 100644 desktop/src/components/shared/Toast.test.tsx create mode 100644 src/server/services/diagnosticsShare.test.ts create mode 100644 src/server/services/diagnosticsShare.ts create mode 100644 src/utils/diagLogs.test.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ea7fd0fc..73ff109b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -13,6 +13,7 @@ assignees: '' - [ ] 我已经升级到最新版本后复现过这个问题 - [ ] 我已经搜索过[现有 issues](https://github.com/NanmiCoder/cc-haha/issues),确认没有重复问题 - [ ] 我已经隐藏截图和日志中的 API Key、Token、Cookie 等敏感信息 +- [ ] 我已在分享前检查自动生成的诊断报告或诊断包,确认其中没有不应公开的私密元数据 ## 问题描述 @@ -56,6 +57,16 @@ assignees: '' 在此粘贴错误信息和日志 ``` +## 诊断信息(可选) + + +- 相关 Event IDs: +- 已粘贴到 Issue 正文的诊断报告: 是 / 否 +- 诊断包文件附件: + ## 截图或录屏 @@ -65,5 +76,3 @@ assignees: '' --- - - diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index faf62e66..fc39f6e4 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { ELECTRON_EVENT_CHANNELS, ELECTRON_INTERNAL_CHANNELS, ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './ipc/channels' import { isElectronIpcChannel, validateElectronIpcPayload } from './ipc/capabilities' import { ElectronServerRuntime } from './services/serverRuntime' +import { electronHostDiagnosticsFile } from './services/sidecarManager' import { openDialog, saveDialog } from './services/dialogs' import { openExternalUrl, openSystemPath, openSystemSettingsUrl } from './services/shell' import { @@ -139,6 +140,7 @@ function getServerRuntime() { desktopRoot: unpackedRoot(), appRoot: appRoot(), h5DistDir: path.join(unpackedRoot(), 'dist'), + diagnosticsFile: electronHostDiagnosticsFile(process.env), resolveSystemProxy: (url) => session.defaultSession.resolveProxy(url), }) return serverRuntime diff --git a/desktop/electron/services/serverRuntime.test.ts b/desktop/electron/services/serverRuntime.test.ts new file mode 100644 index 00000000..9ebf72af --- /dev/null +++ b/desktop/electron/services/serverRuntime.test.ts @@ -0,0 +1,334 @@ +import { EventEmitter } from 'node:events' +import { mkdtempSync, rmSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import path from 'node:path' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SidecarChild, SidecarPlan } from './sidecarManager' +import { ElectronServerRuntime } from './serverRuntime' + +const sidecarMocks = { + nextPort: 49321, + spawnError: null as Error | null, + serverChildren: [] as FakeSidecarChild[], + adapterChildren: [] as FakeSidecarChild[], + serverPlans: [] as SidecarPlan[], + appendHostDiagnostic: vi.fn(), + waitForServerImpl: () => Promise.resolve(), + onAdapterSpawn: null as (() => void) | null, + spawnSidecar: vi.fn((plan: SidecarPlan) => { + if (plan.args[0] === 'server' && sidecarMocks.spawnError) throw sidecarMocks.spawnError + const child = new FakeSidecarChild() + if (plan.args[0] === 'server') { + sidecarMocks.serverChildren.push(child) + sidecarMocks.serverPlans.push(plan) + } else { + sidecarMocks.adapterChildren.push(child) + sidecarMocks.onAdapterSpawn?.() + } + return child as unknown as SidecarChild + }), +} + +let isolatedConfigDir = '' + +class FakeSidecarChild extends EventEmitter { + readonly stdout = new PassThrough() + readonly stderr = new PassThrough() + readonly kill = vi.fn() +} + +function createRuntime(options: { appRoot?: string, diagnosticsFile?: string } = {}) { + return new ElectronServerRuntime({ + desktopRoot: '/isolated/desktop', + appRoot: options.appRoot, + diagnosticsFile: options.diagnosticsFile, + env: { CLAUDE_CONFIG_DIR: isolatedConfigDir }, + deps: { + appendHostDiagnostic: sidecarMocks.appendHostDiagnostic, + preferredServerPorts: () => [], + reserveServerPort: async () => sidecarMocks.nextPort++, + spawnSidecar: sidecarMocks.spawnSidecar, + waitForServer: async () => await sidecarMocks.waitForServerImpl(), + writeLastServerPort: () => undefined, + }, + }) +} + +async function waitForServerChildren(count: number): Promise { + for (let attempt = 0; attempt < 20 && sidecarMocks.serverChildren.length !== count; attempt++) { + await new Promise(resolve => setTimeout(resolve, 0)) + } + expect(sidecarMocks.serverChildren).toHaveLength(count) +} + +describe('ElectronServerRuntime', () => { + beforeEach(() => { + isolatedConfigDir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-runtime-')) + sidecarMocks.nextPort = 49321 + sidecarMocks.spawnError = null + sidecarMocks.serverChildren.length = 0 + sidecarMocks.adapterChildren.length = 0 + sidecarMocks.serverPlans.length = 0 + sidecarMocks.appendHostDiagnostic.mockClear() + sidecarMocks.waitForServerImpl = () => Promise.resolve() + sidecarMocks.onAdapterSpawn = null + sidecarMocks.spawnSidecar.mockClear() + }) + + afterEach(() => { + rmSync(isolatedConfigDir, { recursive: true, force: true }) + }) + + it('restarts after the active healthy server exits and ignores its late exit', async () => { + const runtime = createRuntime({ + appRoot: '/isolated/app', + }) + + const firstUrl = await runtime.getServerUrl() + const firstChild = sidecarMocks.serverChildren[0]! + const firstAdapters = [...sidecarMocks.adapterChildren] + expect(firstAdapters).toHaveLength(5) + firstChild.emit('exit', 7, null) + + const [secondUrl, coalescedUrl] = await Promise.all([ + runtime.getServerUrl(), + runtime.getServerUrl(), + ]) + const secondChild = sidecarMocks.serverChildren[1]! + firstChild.emit('exit', 9, 'SIGTERM') + + expect(firstUrl).toBe('http://127.0.0.1:49321') + expect(secondUrl).toBe('http://127.0.0.1:49322') + expect(coalescedUrl).toBe(secondUrl) + expect(sidecarMocks.serverChildren).toHaveLength(2) + expect(sidecarMocks.adapterChildren).toHaveLength(10) + for (const adapter of firstAdapters) expect(adapter.kill).toHaveBeenCalledTimes(1) + for (const adapter of sidecarMocks.adapterChildren.slice(5)) { + expect(adapter.kill).not.toHaveBeenCalled() + } + expect(await runtime.getServerUrl()).toBe(secondUrl) + expect(secondChild).toBeDefined() + }) + + it('passes the isolated Electron host diagnostics file to the server sidecar', async () => { + const runtime = createRuntime({ + diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log', + }) + + await runtime.startServer() + + expect(sidecarMocks.serverPlans[0]!.env.CC_HAHA_ELECTRON_DIAGNOSTICS_FILE) + .toBe('/isolated/user-data/diagnostics/electron-host.log') + expect(sidecarMocks.serverPlans[0]!.env.CLAUDE_CONFIG_DIR).toBe(isolatedConfigDir) + expect(sidecarMocks.serverPlans[0]!.env.CLAUDE_CONFIG_DIR) + .not.toBe(path.join(homedir(), '.claude')) + }) + + it('persists a server startup failure through the sanitized host-log boundary', async () => { + sidecarMocks.spawnError = new Error('spawn failed') + const runtime = createRuntime({ + diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log', + }) + + await expect(runtime.startServer()).rejects.toThrow('spawn failed') + + expect(sidecarMocks.appendHostDiagnostic).toHaveBeenCalledWith( + '/isolated/user-data/diagnostics/electron-host.log', + expect.stringContaining('[startup-error] spawn failed'), + ) + }) + + it('rejects an in-flight start when the child exits before health publication', async () => { + sidecarMocks.waitForServerImpl = () => new Promise(() => undefined) + const runtime = createRuntime() + + const starting = runtime.startServer() + await waitForServerChildren(1) + sidecarMocks.serverChildren[0]!.emit('exit', 17, null) + + await expect(starting).rejects.toThrow('code=17, signal=null') + sidecarMocks.waitForServerImpl = () => Promise.resolve() + await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322') + expect(sidecarMocks.serverChildren).toHaveLength(2) + }) + + it('kills the attempted server child when the health wait rejects', async () => { + sidecarMocks.waitForServerImpl = () => Promise.reject(new Error('health wait timed out')) + const runtime = createRuntime() + + await expect(runtime.startServer()).rejects.toThrow('health wait timed out') + + expect(sidecarMocks.serverChildren).toHaveLength(1) + expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1) + expect(sidecarMocks.adapterChildren).toHaveLength(0) + }) + + it('kills an unpublished server exactly once when stopAll runs during health wait', async () => { + let releaseHealth!: () => void + sidecarMocks.waitForServerImpl = () => new Promise(resolve => { + releaseHealth = resolve + }) + const runtime = createRuntime() + + const starting = runtime.startServer() + await waitForServerChildren(1) + runtime.stopAll(true) + + expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1) + await expect(starting).rejects.toThrow('stopped') + releaseHealth() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(sidecarMocks.serverChildren).toHaveLength(1) + expect(sidecarMocks.adapterChildren).toHaveLength(0) + expect(sidecarMocks.serverChildren[0]!.kill).toHaveBeenCalledTimes(1) + }) + + it('stops active adapters immediately when the server exits without restart demand', async () => { + const runtime = createRuntime() + await runtime.startServer() + const activeAdapters = [...sidecarMocks.adapterChildren] + + sidecarMocks.serverChildren[0]!.emit('exit', 19, null) + + for (const adapter of activeAdapters) { + expect(adapter.kill).toHaveBeenCalledTimes(1) + } + expect(sidecarMocks.serverChildren).toHaveLength(1) + }) + + it('stops active adapters immediately when the server emits a process error', async () => { + const runtime = createRuntime() + await runtime.startServer() + const activeAdapters = [...sidecarMocks.adapterChildren] + + sidecarMocks.serverChildren[0]!.emit('error', new Error('active server failed')) + + for (const adapter of activeAdapters) { + expect(adapter.kill).toHaveBeenCalledTimes(1) + } + }) + + it('does not let a stale server exit stop replacement adapters', async () => { + const runtime = createRuntime() + await runtime.startServer() + const firstServer = sidecarMocks.serverChildren[0]! + firstServer.emit('exit', 20, null) + await runtime.getServerUrl() + const replacementAdapters = sidecarMocks.adapterChildren.slice(5) + + firstServer.emit('exit', 21, 'SIGTERM') + + expect(replacementAdapters).toHaveLength(5) + for (const adapter of replacementAdapters) { + expect(adapter.kill).not.toHaveBeenCalled() + } + }) + + it('stops the current adapter generation after an explicit adapter restart', async () => { + const runtime = createRuntime() + await runtime.startServer() + const firstAdapters = [...sidecarMocks.adapterChildren] + + await runtime.restartAdaptersSidecars() + const restartedAdapters = sidecarMocks.adapterChildren.slice(5) + sidecarMocks.serverChildren[0]!.emit('exit', 22, null) + + for (const adapter of firstAdapters) { + expect(adapter.kill).toHaveBeenCalledTimes(1) + } + for (const adapter of restartedAdapters) { + expect(adapter.kill).toHaveBeenCalledTimes(1) + } + }) + + it('coalesces overlapping manual adapter restarts into one live generation', async () => { + const runtime = createRuntime() + await runtime.startServer() + const originalAdapters = [...sidecarMocks.adapterChildren] + + const firstRestart = runtime.restartAdaptersSidecars() + const secondRestart = runtime.restartAdaptersSidecars() + + expect(secondRestart).toBe(firstRestart) + await Promise.all([firstRestart, secondRestart]) + expect(sidecarMocks.adapterChildren).toHaveLength(10) + for (const adapter of originalAdapters) { + expect(adapter.kill).toHaveBeenCalledTimes(1) + } + for (const adapter of sidecarMocks.adapterChildren.slice(5)) { + expect(adapter.kill).not.toHaveBeenCalled() + } + }) + + it('cancels a manual adapter restart when its server exits after the first spawn', async () => { + const runtime = createRuntime() + await runtime.startServer() + const firstServer = sidecarMocks.serverChildren[0]! + const originalAdapters = [...sidecarMocks.adapterChildren] + sidecarMocks.onAdapterSpawn = () => { + sidecarMocks.onAdapterSpawn = null + firstServer.emit('exit', 23, null) + } + + await runtime.restartAdaptersSidecars() + + expect(sidecarMocks.adapterChildren).toHaveLength(6) + for (const adapter of originalAdapters) { + expect(adapter.kill).toHaveBeenCalledTimes(1) + } + expect(sidecarMocks.adapterChildren[5]!.kill).toHaveBeenCalledTimes(1) + + await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322') + expect(sidecarMocks.serverChildren).toHaveLength(2) + expect(sidecarMocks.adapterChildren).toHaveLength(11) + for (const adapter of sidecarMocks.adapterChildren.slice(6)) { + expect(adapter.kill).not.toHaveBeenCalled() + } + }) + + it('rejects when the published child exits during adapter startup', async () => { + const runtime = createRuntime() + sidecarMocks.onAdapterSpawn = () => { + sidecarMocks.onAdapterSpawn = null + sidecarMocks.serverChildren[0]!.emit('exit', 18, 'SIGTERM') + } + + await expect(runtime.startServer()).rejects.toThrow('code=18, signal=SIGTERM') + + expect(sidecarMocks.adapterChildren).toHaveLength(1) + expect(sidecarMocks.adapterChildren[0]!.kill).toHaveBeenCalledTimes(1) + + await expect(runtime.getServerUrl()).resolves.toBe('http://127.0.0.1:49322') + expect(sidecarMocks.serverChildren).toHaveLength(2) + expect(sidecarMocks.adapterChildren).toHaveLength(6) + for (const adapter of sidecarMocks.adapterChildren.slice(1)) { + expect(adapter.kill).not.toHaveBeenCalled() + } + }) + + it('handles an asynchronous child process error without crashing Electron', async () => { + sidecarMocks.waitForServerImpl = () => new Promise(() => undefined) + const runtime = createRuntime({ + diagnosticsFile: '/isolated/user-data/diagnostics/electron-host.log', + }) + + const starting = runtime.startServer() + await waitForServerChildren(1) + expect(() => sidecarMocks.serverChildren[0]!.emit( + 'error', + new Error('spawn error OPENAI_API_KEY=unsafe-value'), + )).not.toThrow() + + const rejection = await starting.then( + () => null, + error => error as Error, + ) + expect(rejection?.message).toContain('spawn error') + expect(rejection?.message).not.toContain('unsafe-value') + expect(sidecarMocks.appendHostDiagnostic).toHaveBeenCalledWith( + '/isolated/user-data/diagnostics/electron-host.log', + expect.stringContaining('[process-error] sidecar process error: spawn error'), + ) + }) +}) diff --git a/desktop/electron/services/serverRuntime.ts b/desktop/electron/services/serverRuntime.ts index ba4af210..db2dc793 100644 --- a/desktop/electron/services/serverRuntime.ts +++ b/desktop/electron/services/serverRuntime.ts @@ -1,7 +1,9 @@ import path from 'node:path' import { + appendHostDiagnostic, createAdapterPlan, createServerPlan, + ELECTRON_DIAGNOSTICS_FILE_ENV, formatStartupError, killSidecar, mergeProxyEnv, @@ -10,6 +12,7 @@ import { proxyUrlFromElectronProxyRules, pushStartupLog, reserveServerPort, + sanitizeHostDiagnostic, SERVER_BIND_HOST, SERVER_CONTROL_HOST, SERVER_STARTUP_TIMEOUT_MS, @@ -25,24 +28,91 @@ type ServerRuntimeOptions = { desktopRoot: string appRoot?: string h5DistDir?: string + diagnosticsFile?: string + env?: NodeJS.ProcessEnv + deps?: Partial resolveSystemProxy?: (url: string) => Promise } +type ServerRuntimeDeps = { + appendHostDiagnostic: typeof appendHostDiagnostic + preferredServerPorts: typeof preferredServerPorts + reserveServerPort: typeof reserveServerPort + spawnSidecar: typeof spawnSidecar + waitForServer: typeof waitForServer + writeLastServerPort: typeof writeLastServerPort +} + +const DEFAULT_SERVER_RUNTIME_DEPS: ServerRuntimeDeps = { + appendHostDiagnostic, + preferredServerPorts, + reserveServerPort, + spawnSidecar, + waitForServer, + writeLastServerPort, +} + +type ServerStartState = { + child: SidecarChild + adapterChildren: SidecarChild[] + childStopped: boolean + readonly failure: Error | null + failurePromise: Promise + fail: (error: Error) => void +} + +type ActiveServer = { + url: string + child: SidecarChild + adapterChildren: SidecarChild[] +} + +function createServerStartState(child: SidecarChild): ServerStartState { + let failure: Error | null = null + let rejectFailure!: (error: Error) => void + const failurePromise = new Promise((_resolve, reject) => { + rejectFailure = reject + }) + return { + child, + adapterChildren: [], + childStopped: false, + get failure() { + return failure + }, + failurePromise, + fail(error) { + if (failure) return + failure = error + rejectFailure(error) + }, + } +} + export class ElectronServerRuntime { private readonly desktopRoot: string private readonly appRoot: string private readonly h5DistDir: string + private readonly diagnosticsFile?: string + private readonly baseEnv: NodeJS.ProcessEnv + private readonly deps: ServerRuntimeDeps private readonly resolveSystemProxy?: (url: string) => Promise private sidecarEnvPromise: Promise | null = null - private server: { url: string, child: SidecarChild } | null = null + private server: ActiveServer | null = null private adapters: SidecarChild[] = [] private startupError: string | null = null + private restartAfterExit = false private startPromise: Promise | null = null + private startingServer: ServerStartState | null = null + private adapterRestartPromise: Promise | null = null constructor(options: ServerRuntimeOptions) { this.desktopRoot = options.desktopRoot this.appRoot = options.appRoot ?? options.desktopRoot this.h5DistDir = options.h5DistDir ?? path.join(options.desktopRoot, 'dist') + this.diagnosticsFile = options.diagnosticsFile + this.baseEnv = options.env ?? process.env + this.deps = { ...DEFAULT_SERVER_RUNTIME_DEPS, ...options.deps } this.resolveSystemProxy = options.resolveSystemProxy } @@ -50,6 +120,7 @@ export class ElectronServerRuntime { if (this.server) return this.server.url if (this.startPromise) return this.startPromise + this.restartAfterExit = false this.startPromise = this.startServerOnce() try { return await this.startPromise @@ -60,17 +131,41 @@ export class ElectronServerRuntime { async getServerUrl(): Promise { if (this.server) return this.server.url - if (this.startupError) throw new Error(this.startupError) + if (this.startPromise) return await this.startServer() + if (this.startupError && !this.restartAfterExit) throw new Error(this.startupError) return await this.startServer() } - async restartAdaptersSidecars(): Promise { - this.stopAdaptersSidecars() + restartAdaptersSidecars(): Promise { + if (this.adapterRestartPromise) return this.adapterRestartPromise + const operation = this.restartAdaptersSidecarsOnce() + const tracked = operation.finally(() => { + if (this.adapterRestartPromise === tracked) this.adapterRestartPromise = null + }) + this.adapterRestartPromise = tracked + return tracked + } + + private async restartAdaptersSidecarsOnce(): Promise { const serverUrl = await this.getServerUrl() - await this.startAdaptersSidecars(serverUrl) + const server = this.server + if (!server || server.url !== serverUrl) return + this.stopAdapterChildren(server.adapterChildren) + await this.startAdaptersSidecars(serverUrl, undefined, server) } stopAll(sync = false) { + const starting = this.startingServer + if (starting) { + this.startingServer = null + this.stopAdaptersForStart(starting, sync) + if (this.server?.child === starting.child) this.server = null + starting.fail(new Error('server startup stopped')) + if (!starting.childStopped) { + starting.childStopped = true + killSidecar(starting.child, sync) + } + } this.stopAdaptersSidecars(sync) if (this.server) { killSidecar(this.server.child, sync) @@ -81,36 +176,84 @@ export class ElectronServerRuntime { private async startServerOnce(): Promise { // Prefer the configured fixed port, then the previous run's port, so // phone bookmarks / QR codes / reverse proxies survive restarts (#767). - const port = await reserveServerPort(SERVER_BIND_HOST, preferredServerPorts()) + const port = await this.deps.reserveServerPort( + SERVER_BIND_HOST, + this.deps.preferredServerPorts(this.baseEnv), + ) const url = `http://${SERVER_CONTROL_HOST}:${port}` const logs: string[] = [] + let startState: ServerStartState | null = null const env = await this.resolveSidecarBaseEnv() const plan = createServerPlan({ desktopRoot: this.desktopRoot, appRoot: this.appRoot, port, h5DistDir: this.h5DistDir, - env, + env: this.diagnosticsFile + ? { ...env, [ELECTRON_DIAGNOSTICS_FILE_ENV]: this.diagnosticsFile } + : env, }) try { - const child = spawnSidecar(plan) - this.captureLogs(child, 'claude-server', logs) - await waitForServer(SERVER_CONTROL_HOST, port, SERVER_STARTUP_TIMEOUT_MS) - writeLastServerPort(port) - this.server = { url, child } + const child = this.deps.spawnSidecar(plan) + startState = createServerStartState(child) + this.startingServer = startState + this.captureLogs(child, 'claude-server', logs, (code, signal) => { + this.handleServerExit(child, code, signal, logs) + }, error => { + this.handleServerError(child, error, logs) + }) + await Promise.race([ + this.deps.waitForServer(SERVER_CONTROL_HOST, port, SERVER_STARTUP_TIMEOUT_MS), + startState.failurePromise, + ]) + if (startState.failure) throw startState.failure + this.deps.writeLastServerPort(port, this.baseEnv) + this.server = { url, child, adapterChildren: startState.adapterChildren } + const activeServer = this.server this.startupError = null - await this.startAdaptersSidecars(url) + this.stopAdaptersSidecars() + await Promise.race([ + this.startAdaptersSidecars(url, startState, activeServer), + startState.failurePromise, + ]) + if (startState.failure) throw startState.failure return url } catch (error) { + if (startState) { + this.stopAdaptersForStart(startState) + if (this.server?.child === startState.child) this.server = null + if (!startState.childStopped) { + startState.childStopped = true + killSidecar(startState.child) + } + } + if (startState?.failure) { + throw new Error(this.startupError ?? startState.failure.message) + } const message = error instanceof Error ? error.message : String(error) + this.deps.appendHostDiagnostic(this.diagnosticsFile, `[claude-server] [startup-error] ${message}`) this.startupError = formatStartupError(message, logs) throw new Error(this.startupError) + } finally { + if (this.startingServer === startState) this.startingServer = null } } - private async startAdaptersSidecars(serverUrl: string): Promise { + private async startAdaptersSidecars( + serverUrl: string, + startState?: ServerStartState, + activeServer?: ActiveServer, + ): Promise { const env = await this.resolveSidecarBaseEnv() + const isCurrentGeneration = () => { + if (startState?.failure) return false + if (activeServer && this.server !== activeServer) return false + return true + } + if (!isCurrentGeneration()) return + const ownedAdapters = startState?.adapterChildren + ?? activeServer?.adapterChildren for (const [label, flag] of [ ['feishu', '--feishu'], ['telegram', '--telegram'], @@ -118,8 +261,9 @@ export class ElectronServerRuntime { ['dingtalk', '--dingtalk'], ['whatsapp', '--whatsapp'], ] as const) { + if (!isCurrentGeneration()) break try { - const child = spawnSidecar(createAdapterPlan({ + const child = this.deps.spawnSidecar(createAdapterPlan({ desktopRoot: this.desktopRoot, appRoot: this.appRoot, h5DistDir: this.h5DistDir, @@ -127,8 +271,13 @@ export class ElectronServerRuntime { flag, env, })) + if (!isCurrentGeneration()) { + killSidecar(child) + break + } this.captureLogs(child, `claude-adapters:${label}`) this.adapters.push(child) + ownedAdapters?.push(child) } catch (error) { console.error(`[desktop] failed to start ${label} adapter sidecar`, error) } @@ -136,29 +285,104 @@ export class ElectronServerRuntime { } private stopAdaptersSidecars(sync = false) { - for (const child of this.adapters.splice(0)) { + const children = this.adapters.splice(0) + this.removeOwnedAdapters(this.server?.adapterChildren, children) + this.removeOwnedAdapters(this.startingServer?.adapterChildren, children) + for (const child of children) { killSidecar(child, sync) } } - private captureLogs(child: SidecarChild, label: string, startupLogs?: string[]) { + private removeOwnedAdapters(owned: SidecarChild[] | undefined, removed: SidecarChild[]) { + if (!owned?.length || !removed.length) return + const removedSet = new Set(removed) + const retained = owned.filter(child => !removedSet.has(child)) + owned.splice(0, owned.length, ...retained) + } + + private stopAdaptersForStart(startState: ServerStartState, sync = false) { + this.stopAdapterChildren(startState.adapterChildren, sync) + } + + private captureLogs( + child: SidecarChild, + label: string, + startupLogs?: string[], + onExit?: (code: number | null, signal: NodeJS.Signals | null) => void, + onError?: (error: Error) => void, + ) { child.stdout.on('data', chunk => { const line = String(chunk).trimEnd() if (!line) return console.log(`[${label}] ${line}`) + this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [stdout] ${line}`) if (startupLogs) pushStartupLog(startupLogs, `[stdout] ${line}`) }) child.stderr.on('data', chunk => { const line = String(chunk).trimEnd() if (!line) return console.error(`[${label}] ${line}`) + this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [stderr] ${line}`) if (startupLogs) pushStartupLog(startupLogs, `[stderr] ${line}`) }) child.on('exit', (code, signal) => { const line = `sidecar exited (code=${code}, signal=${signal})` console.log(`[${label}] ${line}`) + this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [exit] ${line}`) if (startupLogs) pushStartupLog(startupLogs, `[exit] ${line}`) + onExit?.(code, signal) }) + child.on('error', error => { + const message = error instanceof Error ? error.message : String(error) + const line = `sidecar process error: ${message}` + console.error(`[${label}] ${sanitizeHostDiagnostic(line)}`) + this.deps.appendHostDiagnostic(this.diagnosticsFile, `[${label}] [process-error] ${line}`) + if (startupLogs) pushStartupLog(startupLogs, `[process-error] ${line}`) + onError?.(error instanceof Error ? error : new Error(message)) + }) + } + + private handleServerExit( + child: SidecarChild, + code: number | null, + signal: NodeJS.Signals | null, + logs: string[], + ) { + this.handleServerFailure( + child, + `server sidecar exited after spawn (code=${code}, signal=${signal})`, + logs, + ) + } + + private handleServerError(child: SidecarChild, error: Error, logs: string[]) { + this.handleServerFailure( + child, + `server sidecar process error after spawn: ${sanitizeHostDiagnostic(error.message)}`, + logs, + ) + } + + private handleServerFailure(child: SidecarChild, message: string, logs: string[]) { + const active = this.server?.child === child + const starting = this.startingServer?.child === child + if (!active && !starting) return + if (active) { + const adapterChildren = this.server!.adapterChildren + this.server = null + this.stopAdapterChildren(adapterChildren) + } + this.restartAfterExit = true + this.startupError = formatStartupError(message, logs) + if (starting) this.startingServer?.fail(new Error(message)) + } + + private stopAdapterChildren(children: SidecarChild[], sync = false) { + for (const child of children.splice(0)) { + const index = this.adapters.indexOf(child) + if (index >= 0) this.adapters.splice(index, 1) + killSidecar(child, sync) + } } private async resolveSidecarBaseEnv(): Promise { @@ -167,17 +391,17 @@ export class ElectronServerRuntime { } private async resolveSidecarBaseEnvOnce(): Promise { - if (!this.resolveSystemProxy) return this.applyPowerShellOverride(process.env) + if (!this.resolveSystemProxy) return this.applyPowerShellOverride(this.baseEnv) try { const rules = await this.resolveSystemProxy('https://auth.openai.com/') return this.applyPowerShellOverride(mergeProxyEnv( - process.env, + this.baseEnv, proxyUrlFromElectronProxyRules(rules), )) } catch (error) { console.error('[desktop] failed to resolve system proxy for sidecars', error) - return this.applyPowerShellOverride(process.env) + return this.applyPowerShellOverride(this.baseEnv) } } diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index cdd2e875..ba86a6bd 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -2,13 +2,17 @@ import { describe, expect, it, vi } from 'vitest' import net from 'node:net' import http from 'node:http' import path from 'node:path' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' import { + appendHostDiagnostic, buildSidecarEnv, createAdapterPlan, createServerPlan, + electronHostDiagnosticsFile, httpToWebSocketUrl, + HOST_DIAGNOSTICS_BYTE_LIMIT, + HOST_DIAGNOSTICS_LINE_LIMIT, killSidecar, mergeProxyEnv, parseH5FixedPort, @@ -51,6 +55,23 @@ function close(server: http.Server): Promise { } describe('Electron sidecar manager', () => { + it('places the Electron host log in the active server diagnostics directory', () => { + const portableDir = path.join(tmpdir(), 'cc-haha-portable-diagnostics') + + expect(electronHostDiagnosticsFile( + { CLAUDE_CONFIG_DIR: portableDir }, + path.join(tmpdir(), 'unused-home'), + )).toBe(path.join(portableDir, 'cc-haha', 'diagnostics', 'electron-host.log')) + }) + + it('resolves the default Electron host log without consulting real user state', () => { + const isolatedHome = path.resolve(path.sep, '__cc_haha_injected_test_home__') + + expect(electronHostDiagnosticsFile({}, isolatedHome)).toBe( + path.join(isolatedHome, '.claude', 'cc-haha', 'diagnostics', 'electron-host.log'), + ) + }) + it('maps host platform to existing sidecar target triples', () => { expect(resolveHostTriple('darwin', 'arm64')).toBe('aarch64-apple-darwin') expect(resolveHostTriple('darwin', 'x64')).toBe('x86_64-apple-darwin') @@ -160,6 +181,95 @@ describe('Electron sidecar manager', () => { expect(logs[0]).toBe('line 5') }) + it('sanitizes the bounded startup tail before it reaches an error surface', () => { + const logs: string[] = [] + pushStartupLog( + logs, + `Bearer startup.secret sk-proj-STARTUPSECRETVALUE https://alice:password@example.com ${homedir()}/project`, + ) + + expect(logs[0]).toContain('Bearer [REDACTED]') + expect(logs[0]).toContain('https://[REDACTED]@example.com/') + expect(logs[0]).toContain('[HOME]/project') + expect(logs[0]).not.toContain('startup.secret') + expect(logs[0]).not.toContain('sk-proj-STARTUPSECRETVALUE') + expect(logs[0]).not.toContain(homedir()) + }) + + it('appends only a bounded sanitized Electron host-log tail', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-')) + const logPath = path.join(dir, 'electron-host.log') + const homeDir = path.join(dir, 'private-home') + try { + for (let index = 0; index < HOST_DIAGNOSTICS_LINE_LIMIT + 5; index++) { + appendHostDiagnostic(logPath, `line ${index}`, { homeDir }) + } + appendHostDiagnostic( + logPath, + `Authorization: Bearer bearer.secret api_key=sk-ant-api03-PRIVATE ANTHROPIC_API_KEY=anthropic-secret OPENAI_API_KEY="openai-secret" MINIMAX_AUTH_TOKEN='minimax-secret' https://alice:password@example.com/private ${homeDir}/project`, + { homeDir }, + ) + + const contents = readFileSync(logPath, 'utf-8') + const lines = contents.trimEnd().split('\n') + expect(contents).toContain('Bearer [REDACTED]') + expect(contents).toContain('api_key=[REDACTED]') + expect(contents).toContain('ANTHROPIC_API_KEY=[REDACTED]') + expect(contents).toContain('OPENAI_API_KEY=[REDACTED]') + expect(contents).toContain('MINIMAX_AUTH_TOKEN=[REDACTED]') + expect(contents).toContain('https://[REDACTED]@example.com/private') + expect(contents).toContain('[HOME]/project') + expect(contents).not.toContain('bearer.secret') + expect(contents).not.toContain('sk-ant-api03-PRIVATE') + expect(contents).not.toContain('anthropic-secret') + expect(contents).not.toContain('openai-secret') + expect(contents).not.toContain('minimax-secret') + expect(contents).not.toContain('alice:password') + expect(contents).not.toContain(homeDir) + expect(lines).toHaveLength(HOST_DIAGNOSTICS_LINE_LIMIT) + expect(lines[0]).toBe('line 6') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('bounds and re-sanitizes an oversized pre-existing host diagnostics file', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-existing-')) + const logPath = path.join(dir, 'electron-host.log') + const homeDir = path.join(dir, 'private-home') + try { + writeFileSync( + logPath, + `${'oversized-old-data '.repeat(HOST_DIAGNOSTICS_BYTE_LIMIT)}\nOPENAI_API_KEY=old-secret ${homeDir}/private\n`, + 'utf-8', + ) + + appendHostDiagnostic(logPath, 'latest safe diagnostic', { homeDir }) + + const contents = readFileSync(logPath, 'utf-8') + expect(statSync(logPath).size).toBeLessThanOrEqual(HOST_DIAGNOSTICS_BYTE_LIMIT) + expect(contents.trimEnd().split('\n').length).toBeLessThanOrEqual(HOST_DIAGNOSTICS_LINE_LIMIT) + expect(contents).toContain('latest safe diagnostic') + expect(contents).toContain('OPENAI_API_KEY=[REDACTED]') + expect(contents).not.toContain('old-secret') + expect(contents).not.toContain(homeDir) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('does not crash Electron when the host diagnostics destination cannot be written', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'cc-haha-electron-host-failure-')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + expect(() => appendHostDiagnostic(dir, 'sidecar failed')).not.toThrow() + expect(errorSpy).toHaveBeenCalledWith('[desktop] failed to persist Electron host diagnostics') + } finally { + errorSpy.mockRestore() + rmSync(dir, { recursive: true, force: true }) + } + }) + it('maps http urls to adapter websocket urls', () => { expect(httpToWebSocketUrl('http://127.0.0.1:3456')).toBe('ws://127.0.0.1:3456') expect(httpToWebSocketUrl('https://example.com')).toBe('wss://example.com') diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index 99331237..dd5cb9d5 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -1,5 +1,16 @@ import { spawn, spawnSync, type ChildProcessByStdio } from 'node:child_process' -import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs' +import { + closeSync, + existsSync, + fstatSync, + mkdirSync, + openSync, + readFileSync, + readSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs' import type { Readable } from 'node:stream' import net from 'node:net' import os from 'node:os' @@ -10,6 +21,10 @@ export const SERVER_BIND_HOST = '0.0.0.0' export const SERVER_CONTROL_HOST = '127.0.0.1' export const SERVER_STARTUP_TIMEOUT_MS = 30_000 export const SERVER_STARTUP_LOG_LIMIT = 80 +export const HOST_DIAGNOSTICS_LINE_LIMIT = 80 +export const HOST_DIAGNOSTICS_BYTE_LIMIT = 256 * 1024 +export const ELECTRON_DIAGNOSTICS_FILE_ENV = 'CC_HAHA_ELECTRON_DIAGNOSTICS_FILE' +const HOST_DIAGNOSTICS_LINE_BYTE_LIMIT = 4096 // Shared with the Tauri shell (src-tauri/src/lib.rs) so both desktop builds // reuse the same sticky port across restarts (issue #767). export const SERVER_STATE_FILE = 'desktop-server-state.json' @@ -128,8 +143,18 @@ export async function reserveServerPort( return await reserveLocalPort(bindHost) } -export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env): string { - return env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') +export function claudeConfigDir( + env: NodeJS.ProcessEnv = process.env, + homeDir = os.homedir(), +): string { + return env.CLAUDE_CONFIG_DIR || path.join(homeDir, '.claude') +} + +export function electronHostDiagnosticsFile( + env: NodeJS.ProcessEnv = process.env, + homeDir = os.homedir(), +): string { + return path.join(claudeConfigDir(env, homeDir), 'cc-haha', 'diagnostics', 'electron-host.log') } /** Parse h5Access.fixedPort out of cc-haha/settings.json contents. */ @@ -238,12 +263,101 @@ function sleep(ms: number): Promise { } export function pushStartupLog(logs: string[], line: string) { - const trimmed = line.trimEnd() + const trimmed = sanitizeHostDiagnostic(line, os.homedir()) if (!trimmed) return if (logs.length >= SERVER_STARTUP_LOG_LIMIT) logs.shift() logs.push(trimmed) } +export function appendHostDiagnostic( + filePath: string | undefined, + line: string, + { homeDir = os.homedir() }: { homeDir?: string } = {}, +): void { + if (!filePath) return + const tempPath = `${filePath}.${process.pid}.tmp` + try { + const sanitized = sanitizeHostDiagnostic(line, homeDir) + if (!sanitized) return + const existing = readHostDiagnosticsTail(filePath) + const lines = existing.trimEnd() + ? existing.trimEnd().split('\n').map(entry => sanitizeHostDiagnostic(entry, homeDir)).filter(Boolean) + : [] + lines.push(sanitized) + const boundedLines: string[] = [] + let retainedBytes = 0 + for (const entry of lines.slice(-HOST_DIAGNOSTICS_LINE_LIMIT).reverse()) { + const entryBytes = Buffer.byteLength(entry, 'utf-8') + 1 + if (retainedBytes + entryBytes > HOST_DIAGNOSTICS_BYTE_LIMIT) break + boundedLines.unshift(entry) + retainedBytes += entryBytes + } + mkdirSync(path.dirname(filePath), { recursive: true }) + writeFileSync(tempPath, `${boundedLines.join('\n')}\n`, { + encoding: 'utf-8', + mode: 0o600, + }) + renameSync(tempPath, filePath) + } catch { + try { + rmSync(tempPath, { force: true }) + } catch { + // Best-effort cleanup must not mask the original diagnostics failure. + } + console.error('[desktop] failed to persist Electron host diagnostics') + } +} + +function readHostDiagnosticsTail(filePath: string): string { + let descriptor: number | undefined + try { + descriptor = openSync(filePath, 'r') + const size = fstatSync(descriptor).size + const length = Math.min(size, HOST_DIAGNOSTICS_BYTE_LIMIT) + const buffer = Buffer.alloc(length) + const bytesRead = readSync(descriptor, buffer, 0, length, size - length) + const tail = buffer.subarray(0, bytesRead).toString('utf-8') + if (size <= length) return tail + const firstNewline = tail.indexOf('\n') + return firstNewline >= 0 ? tail.slice(firstNewline + 1) : '' + } catch { + return '' + } finally { + if (descriptor !== undefined) closeSync(descriptor) + } +} + +export function sanitizeHostDiagnostic(line: string, homeDir = os.homedir()): string { + let sanitized = line + .replace(/[\r\n]+/g, ' ') + .replace(/https?:\/\/[^\s<>"')\]}]+/gi, candidate => sanitizeUrlUserinfo(candidate)) + .replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]') + .replace( + /\b((?:(?:[a-z0-9]+_)*(?:api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|session[_-]?token|password|secret))\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, + '$1[REDACTED]', + ) + .replace(/\b(?:sk-(?:ant-api03-|proj-)?|ghp_)[A-Za-z0-9_-]{8,}\b/g, '[REDACTED]') + .trimEnd() + if (homeDir) sanitized = sanitized.replaceAll(homeDir, '[HOME]') + return truncateUtf8(sanitized, HOST_DIAGNOSTICS_LINE_BYTE_LIMIT) +} + +function truncateUtf8(value: string, maxBytes: number): string { + const buffer = Buffer.from(value, 'utf-8') + if (buffer.byteLength <= maxBytes) return value + return buffer.subarray(0, maxBytes).toString('utf-8').replace(/\uFFFD$/, '') +} + +function sanitizeUrlUserinfo(candidate: string): string { + try { + const url = new URL(candidate) + if (!url.username && !url.password) return candidate + return `${url.protocol}//[REDACTED]@${url.host}${url.pathname}${url.search}${url.hash}` + } catch { + return '[REDACTED_URL]' + } +} + export function formatStartupError(message: string, logs: string[]): string { const logText = logs.length > 0 ? logs.join('\n') diff --git a/desktop/src/__tests__/diagnosticsSettings.test.tsx b/desktop/src/__tests__/diagnosticsSettings.test.tsx index 592f33e1..df898c7f 100644 --- a/desktop/src/__tests__/diagnosticsSettings.test.tsx +++ b/desktop/src/__tests__/diagnosticsSettings.test.tsx @@ -1,28 +1,64 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom' import { Settings } from '../pages/Settings' +import { SAFE_DOCTOR_STORAGE_KEYS } from '../lib/doctorRepair' +import { useSessionStore } from '../stores/sessionStore' import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' const diagnosticsApiMock = vi.hoisted(() => ({ getStatus: vi.fn(), getEvents: vi.fn(), + getIssueReport: vi.fn(), exportBundle: vi.fn(), openLogDir: vi.fn(), clear: vi.fn(), })) -const doctorRepairMock = vi.hoisted(() => ({ - runDoctorRepair: vi.fn(), +const doctorApiMock = vi.hoisted(() => ({ + report: vi.fn(), })) +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve + reject = nextReject + }) + return { promise, resolve, reject } +} + +function doctorReport(path: string) { + return { + report: { + generatedAt: '2026-07-11T00:00:00.000Z', + items: [{ + id: `finding:${path}`, + label: 'Finding', + kind: 'json' as const, + scope: 'user' as const, + path, + protected: true, + exists: true, + status: 'invalid_schema' as const, + bytes: 42, + }], + protectedSkips: [], + summary: { total: 1, protectedCount: 1, neutralCount: 0, missingCount: 0, invalidCount: 1 }, + }, + } +} + vi.mock('../api/diagnostics', () => ({ diagnosticsApi: diagnosticsApiMock, })) -vi.mock('../lib/doctorRepair', () => doctorRepairMock) +vi.mock('../api/doctor', () => ({ + doctorApi: doctorApiMock, +})) vi.mock('../stores/providerStore', () => ({ useProviderStore: () => ({ @@ -100,23 +136,35 @@ describe('Settings > Diagnostics tab', () => { retentionDays: 7, maxBytes: 50 * 1024 * 1024, totalBytes: 4096, - eventCount: 2, + eventCount: 600, + physicalLineCount: 601, + corruptLineCount: 1, + storageLimitExceeded: false, 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', - details: { - exitCode: 1, - capturedOutput: 'stderr:\nprovider rejected request', + 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', + details: { + exitCode: 1, + capturedOutput: 'stderr:\nprovider rejected request', + }, }, - }], + ...Array.from({ length: 99 }, (_, index) => ({ + id: `event-${index + 2}`, + timestamp: '2026-05-02T00:00:00.000Z', + type: `runtime_event_${index + 2}`, + severity: 'info' as const, + summary: `Runtime event ${index + 2}`, + })), + ], }) diagnosticsApiMock.exportBundle.mockResolvedValue({ bundle: { @@ -125,22 +173,60 @@ describe('Settings > Diagnostics tab', () => { bytes: 1024, }, }) + diagnosticsApiMock.getIssueReport.mockResolvedValue({ + report: '## Diagnostic report\n\n- Event IDs: event-1\n- Private metadata: review before sharing', + }) diagnosticsApiMock.openLogDir.mockResolvedValue({ ok: true }) diagnosticsApiMock.clear.mockResolvedValue({ ok: true }) - doctorRepairMock.runDoctorRepair.mockResolvedValue({ - local: { - removedKeys: ['cc-haha-open-tabs', 'cc-haha-session-runtime'], - missingKeys: ['cc-haha-theme', 'cc-haha-locale', 'cc-haha.persistence.schemaVersion'], - failedKeys: [], + doctorApiMock.report.mockResolvedValue({ + report: { + generatedAt: '2026-07-11T00:00:00.000Z', + items: [ + { + id: 'cc-haha-providers', + label: 'Managed providers', + kind: 'json', + scope: 'user', + path: '~/.claude/cc-haha/providers.json', + protected: true, + exists: true, + status: 'invalid_schema', + bytes: 42, + error: 'providers.0.presetId: expected string', + }, + { + id: 'project-skills', + label: 'Project skills', + kind: 'directory', + scope: 'project', + path: '/.claude/skills', + protected: true, + exists: true, + status: 'ok', + bytes: 0, + }, + ], + protectedSkips: [], + summary: { total: 2, protectedCount: 2, neutralCount: 0, missingCount: 0, invalidCount: 1 }, }, - server: { - ok: true, - }, - serverError: null, }) useSettingsStore.setState({ locale: 'en' }) useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null, toasts: [] }) + useSessionStore.setState({ + sessions: [{ + id: 'session-1', + title: 'Session', + createdAt: '2026-07-11T00:00:00.000Z', + modifiedAt: '2026-07-11T00:00:00.000Z', + messageCount: 0, + projectPath: '/workspace/project', + projectRoot: '/workspace/project', + workDir: '/workspace/project', + workDirExists: true, + }], + activeSessionId: 'session-1', + }) }) it('shows diagnostics status, actions, and recent events', async () => { @@ -155,6 +241,80 @@ describe('Settings > Diagnostics tab', () => { expect(screen.getByText('cli_start_failed')).toBeInTheDocument() expect(screen.getByText('CLI exited during startup with code 1')).toBeInTheDocument() expect(screen.getByText('Details')).toBeInTheDocument() + expect(screen.getByText('600 complete events')).toBeInTheDocument() + expect(screen.getByText(/evidence of 1 corrupt diagnostic record/)).toBeInTheDocument() + expect(screen.getByText('100 visible events')).toBeInTheDocument() + expect(screen.getAllByText('Event ID:')).toHaveLength(100) + expect(screen.getByText('event-1')).toBeInTheDocument() + expect(screen.getByText(/best-effort/i)).toHaveTextContent(/review.*private metadata/i) + + const eventRow = screen.getByText('cli_start_failed').closest('.grid') + expect(eventRow).toHaveClass('grid-cols-1', 'md:grid-cols-[120px_92px_1fr]') + }) + + it('describes persisted corruption evidence accurately when current logs have no physical lines', async () => { + diagnosticsApiMock.getStatus.mockResolvedValueOnce({ + logDir: '/tmp/claude/cc-haha/diagnostics', + diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl', + cliDiagnosticsPath: '/tmp/claude/cc-haha/diagnostics/cli-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: 0, + eventCount: 0, + physicalLineCount: 0, + corruptLineCount: 2, + storageLimitExceeded: false, + recentErrorCount: 0, + lastEventAt: null, + }) + + render() + fireEvent.click(screen.getByText('Diagnostics')) + + const warning = await screen.findByRole('alert') + expect(warning).toHaveTextContent('Detected or retained evidence of 2 corrupt diagnostic records.') + expect(warning).toHaveTextContent('Current diagnostic files contain 0 physical lines.') + expect(warning).not.toHaveTextContent(/among 0 physical lines/i) + }) + + it('explains temporary target overflow while active diagnostic segments are still open', async () => { + diagnosticsApiMock.getStatus.mockResolvedValueOnce({ + logDir: '/tmp/claude/cc-haha/diagnostics', + diagnosticsPath: '/tmp/claude/cc-haha/diagnostics/diagnostics.jsonl', + cliDiagnosticsPath: '/tmp/claude/cc-haha/diagnostics/cli-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: 52 * 1024 * 1024, + eventCount: 10, + physicalLineCount: 10, + corruptLineCount: 0, + storageLimitExceeded: true, + recentErrorCount: 0, + lastEventAt: '2026-07-11T00:00:00.000Z', + }) + + render() + fireEvent.click(screen.getByText('Diagnostics')) + + const warning = await screen.findByRole('alert') + expect(warning).toHaveTextContent('One or more diagnostic surfaces or active writers temporarily exceed their own retention target.') + expect(warning).toHaveTextContent('Cleanup will occur as their segments close or age out.') + expect(warning).not.toHaveTextContent(/50 MB|strict|hard cap/i) + }) + + it('marks the active settings tab and its decorative icon accessibly', async () => { + render() + + const diagnosticsTab = screen.getByRole('button', { name: 'Diagnostics' }) + fireEvent.click(diagnosticsTab) + await screen.findByText('Log directory') + + expect(diagnosticsTab).toHaveAttribute('aria-current', 'page') + expect(diagnosticsTab.querySelector('.material-symbols-outlined')).toHaveAttribute('aria-hidden', 'true') }) it('exports a diagnostics bundle from the settings page', async () => { @@ -239,9 +399,111 @@ describe('Settings > Diagnostics tab', () => { } }) - it('runs Doctor from Diagnostics without clearing unrelated desktop state', async () => { - window.localStorage.setItem('cc-haha-open-tabs', '{"activeTabId":"__settings__"}') - window.localStorage.setItem('cc-haha-theme', 'dark') + it('copies the share-safe issue Markdown 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')), + }, + }) + const writeText = vi.mocked(navigator.clipboard.writeText) + + try { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Copy issue report/i })) + + await waitFor(() => { + expect(execCommand).toHaveBeenCalledWith('copy') + }) + expect(diagnosticsApiMock.getIssueReport).toHaveBeenCalledTimes(1) + expect(writeText).toHaveBeenCalledWith('## Diagnostic report\n\n- Event IDs: event-1\n- Private metadata: review before sharing') + expect(useUIStore.getState().toasts.at(-1)?.message).toBe('Issue report copied.') + } finally { + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: originalExecCommand, + }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }) + } + }) + + it('copies the exact Event ID and reports success', async () => { + const originalClipboard = navigator.clipboard + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + + try { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: 'Copy event ID: event-1' })) + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith('event-1') + }) + expect(writeText).toHaveBeenCalledTimes(1) + expect(useUIStore.getState().toasts.at(-1)?.message).toBe('Event ID copied.') + } finally { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }) + } + }) + + it('reports a meaningful error when Event ID copy fails', async () => { + const originalClipboard = navigator.clipboard + const originalExecCommand = document.execCommand + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')) }, + }) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: vi.fn().mockReturnValue(false), + }) + + try { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: 'Copy event ID: event-1' })) + + await waitFor(() => { + expect(useUIStore.getState().toasts.at(-1)?.message).toBe('Failed to copy event ID.') + }) + } finally { + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: originalExecCommand, + }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: originalClipboard, + }) + } + }) + + it('checks findings first and confirms before resetting only safe desktop state', async () => { + window.localStorage.clear() + for (const key of SAFE_DOCTOR_STORAGE_KEYS) { + window.localStorage.setItem(key, `${key}-value`) + } window.localStorage.setItem('cc-haha-chat-history', 'keep') render() @@ -250,11 +512,256 @@ describe('Settings > Diagnostics tab', () => { fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i })) await waitFor(() => { - expect(doctorRepairMock.runDoctorRepair).toHaveBeenCalled() + expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/project') + }) + expect(window.localStorage.getItem('cc-haha-theme')).toBe('cc-haha-theme-value') + expect(screen.getByText('~/.claude/cc-haha/providers.json')).toBeInTheDocument() + expect(screen.getByText(/Invalid schema/i)).toBeInTheDocument() + expect(screen.getByText(/User and active project/i)).toBeInTheDocument() + expect(screen.getByText('Healthy: 1 · Not configured: 0 · Missing: 0 · Invalid: 1')).toBeInTheDocument() + expect(screen.queryByText('/.claude/skills')).not.toBeInTheDocument() + expect(screen.getByText(/cc-haha-app-zoom/)).toBeInTheDocument() + expect(screen.getByText(/cc-haha-ui-zoom/)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Reset safe UI state/i })) + const dialog = await screen.findByRole('dialog', { name: 'Reset safe UI state' }) + expect(window.localStorage.getItem('cc-haha-theme')).toBe('cc-haha-theme-value') + fireEvent.click(within(dialog).getByRole('button', { name: /Reset safe UI state/i })) + + await waitFor(() => { + expect(doctorApiMock.report).toHaveBeenCalledTimes(2) + }) + for (const key of SAFE_DOCTOR_STORAGE_KEYS) { + expect(window.localStorage.getItem(key)).toBeNull() + } + expect(window.localStorage.getItem('cc-haha-chat-history')).toBe('keep') + expect(screen.getByText(/Removed keys:.*cc-haha-app-zoom/)).toBeInTheDocument() + }) + + it('counts not-configured optional checks separately and excludes them from findings', async () => { + doctorApiMock.report.mockResolvedValueOnce({ + report: { + generatedAt: '2026-07-11T00:00:00.000Z', + items: [ + { + id: 'user-settings', + label: 'User settings', + kind: 'json' as const, + scope: 'user' as const, + path: '~/.claude/settings.json', + protected: true, + exists: true, + status: 'ok' as const, + bytes: 2, + }, + { + id: 'adapters', + label: 'Adapters config', + kind: 'json' as const, + scope: 'user' as const, + path: '~/.claude/adapters.json', + protected: true, + exists: false, + status: 'not_configured' as const, + bytes: 0, + }, + { + id: 'cc-haha-providers', + label: 'Managed providers', + kind: 'json' as const, + scope: 'user' as const, + path: '~/.claude/cc-haha/providers.json', + protected: true, + exists: true, + status: 'invalid_schema' as const, + bytes: 10, + }, + ], + protectedSkips: [], + summary: { total: 3, protectedCount: 3, neutralCount: 1, missingCount: 0, invalidCount: 1 }, + }, }) - const toasts = useUIStore.getState().toasts - expect(toasts[toasts.length - 1]?.message).toContain('Doctor') - expect(window.localStorage.getItem('cc-haha-chat-history')).toBe('keep') + render() + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i })) + + expect(await screen.findByText('Healthy: 1 · Not configured: 1 · Missing: 0 · Invalid: 1')).toBeInTheDocument() + expect(screen.getByText('~/.claude/cc-haha/providers.json')).toBeInTheDocument() + expect(screen.queryByText('~/.claude/adapters.json')).not.toBeInTheDocument() + }) + + it('uses user-only Doctor scope when the active work directory is unavailable', async () => { + useSessionStore.setState((state) => ({ + sessions: state.sessions.map((session) => ({ ...session, workDirExists: false })), + })) + + render() + + fireEvent.click(screen.getByText('Diagnostics')) + expect(await screen.findByText(/User only/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Run Doctor/i })) + + await waitFor(() => { + expect(doctorApiMock.report).toHaveBeenCalledWith(undefined) + }) + }) + + it('clears an existing Doctor report when the active cwd changes', async () => { + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i })) + expect(await screen.findByText('~/.claude/cc-haha/providers.json')).toBeInTheDocument() + + await act(async () => { + useSessionStore.setState((state) => ({ + sessions: [ + ...state.sessions, + { + ...state.sessions[0]!, + id: 'session-missing-workdir', + workDir: '/workspace/missing', + projectRoot: '/workspace/missing', + workDirExists: false, + }, + ], + activeSessionId: 'session-missing-workdir', + })) + }) + + await waitFor(() => { + expect(screen.queryByText('~/.claude/cc-haha/providers.json')).not.toBeInTheDocument() + }) + expect(screen.getByText(/User only/i)).toBeInTheDocument() + }) + + it('keeps newer Doctor loading active when an older response resolves first', async () => { + const oldRequest = deferred>() + const newRequest = deferred>() + doctorApiMock.report + .mockReturnValueOnce(oldRequest.promise) + .mockReturnValueOnce(newRequest.promise) + + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Run Doctor/i })) + expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/project') + + await act(async () => { + useSessionStore.setState((state) => ({ + sessions: [ + ...state.sessions, + { + ...state.sessions[0]!, + id: 'session-new', + workDir: '/workspace/new', + projectRoot: '/workspace/new', + }, + ], + activeSessionId: 'session-new', + })) + }) + await waitFor(() => { + expect(screen.getByRole('button', { name: /Run Doctor/i })).not.toBeDisabled() + }) + fireEvent.click(screen.getByRole('button', { name: /Run Doctor/i })) + expect(doctorApiMock.report).toHaveBeenCalledWith('/workspace/new') + expect(screen.getByRole('button', { name: /Run Doctor/i })).toBeDisabled() + + await act(async () => { + oldRequest.resolve(doctorReport('/old-finding.json')) + await Promise.resolve() + }) + expect(screen.queryByText('/old-finding.json')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Run Doctor/i })).toBeDisabled() + + await act(async () => { + newRequest.resolve(doctorReport('/new-finding.json')) + await Promise.resolve() + }) + expect(await screen.findByText('/new-finding.json')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Run Doctor/i })).not.toBeDisabled() + }) + + it('preserves reset results and releases reset loading when cwd changes during refresh', async () => { + const stalledRefresh = deferred>() + doctorApiMock.report.mockReturnValueOnce(stalledRefresh.promise) + window.localStorage.clear() + for (const key of SAFE_DOCTOR_STORAGE_KEYS) { + window.localStorage.setItem(key, `${key}-value`) + } + + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Reset safe UI state/i })) + const dialog = await screen.findByRole('dialog', { name: 'Reset safe UI state' }) + fireEvent.click(within(dialog).getByRole('button', { name: /Reset safe UI state/i })) + + expect(await screen.findByText(/Removed keys:.*cc-haha-app-zoom/)).toBeInTheDocument() + expect(screen.getByText('Failed keys: None')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Reset safe UI state/i })).toBeDisabled() + + await act(async () => { + useSessionStore.setState((state) => ({ + sessions: [ + ...state.sessions, + { + ...state.sessions[0]!, + id: 'session-reset-new', + workDir: '/workspace/reset-new', + projectRoot: '/workspace/reset-new', + }, + ], + activeSessionId: 'session-reset-new', + })) + }) + + expect(screen.getByText(/Removed keys:.*cc-haha-app-zoom/)).toBeInTheDocument() + expect(screen.getByText('Failed keys: None')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Reset safe UI state/i })).not.toBeDisabled() + + await act(async () => { + stalledRefresh.resolve(doctorReport('/stale-reset-finding.json')) + await Promise.resolve() + }) + expect(screen.queryByText('/stale-reset-finding.json')).not.toBeInTheDocument() + }) + + it('ignores a stale reset refresh rejection after cwd changes', async () => { + const stalledRefresh = deferred>() + doctorApiMock.report.mockReturnValueOnce(stalledRefresh.promise) + + render() + + fireEvent.click(screen.getByText('Diagnostics')) + fireEvent.click(await screen.findByRole('button', { name: /Reset safe UI state/i })) + fireEvent.click(within(await screen.findByRole('dialog', { name: 'Reset safe UI state' })) + .getByRole('button', { name: /Reset safe UI state/i })) + + await act(async () => { + useSessionStore.setState((state) => ({ + sessions: [ + ...state.sessions, + { + ...state.sessions[0]!, + id: 'session-reject-new', + workDir: '/workspace/reject-new', + projectRoot: '/workspace/reject-new', + }, + ], + activeSessionId: 'session-reject-new', + })) + }) + + await act(async () => { + stalledRefresh.reject(new Error('stale reset refresh failed')) + await Promise.resolve() + }) + + expect(useUIStore.getState().toasts.map((toast) => toast.message)).not.toContain('stale reset refresh failed') + expect(screen.getByRole('button', { name: /Reset safe UI state/i })).not.toBeDisabled() }) }) diff --git a/desktop/src/api/diagnostics.ts b/desktop/src/api/diagnostics.ts index 4fb34757..7a65c47b 100644 --- a/desktop/src/api/diagnostics.ts +++ b/desktop/src/api/diagnostics.ts @@ -30,6 +30,9 @@ export type DiagnosticsStatus = { maxBytes: number totalBytes: number eventCount: number + physicalLineCount: number + corruptLineCount: number + storageLimitExceeded: boolean recentErrorCount: number lastEventAt: string | null } @@ -43,6 +46,7 @@ export type DiagnosticsBundle = { export const diagnosticsApi = { getStatus: () => api.get('/api/diagnostics/status'), getEvents: (limit = 100) => api.get<{ events: DiagnosticEvent[] }>(`/api/diagnostics/events?limit=${limit}`), + getIssueReport: () => api.get<{ report: string }>('/api/diagnostics/issue-report'), recordEvent: (event: DiagnosticEventInput) => api.post<{ ok: true }>('/api/diagnostics/events', event, { timeout: 5_000 }), exportBundle: () => api.post<{ bundle: DiagnosticsBundle }>('/api/diagnostics/export', undefined, { timeout: 60_000 }), openLogDir: () => api.post<{ ok: true }>('/api/diagnostics/open-log-dir'), diff --git a/desktop/src/api/doctor.ts b/desktop/src/api/doctor.ts index bb50982e..8a41ac4b 100644 --- a/desktop/src/api/doctor.ts +++ b/desktop/src/api/doctor.ts @@ -8,7 +8,7 @@ export type DoctorReportItem = { path: string protected: boolean exists: boolean - status: 'ok' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'unreadable' + status: 'ok' | 'not_configured' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'invalid_schema' | 'unreadable' bytes: number entryCount?: number lineCount?: number @@ -27,49 +27,15 @@ export type DoctorReport = { summary: { total: number protectedCount: number + neutralCount: number missingCount: number invalidCount: number } } -export type DoctorRepairResult = { - dryRun: true - mutated: false - operations: Array<{ - id: string - path: string - action: 'would_repair' - }> - skips: Array<{ - id: string - path: string - reason: 'protected' - }> - summary: { - operationCount: number - skipCount: number - } -} - -export type DoctorReportRepairResponse = { - ok: boolean - report: DoctorReport - repair: DoctorRepairResult -} - export const doctorApi = { - report: () => api.get<{ report: DoctorReport }>('/api/doctor/report', { timeout: 3_000 }), - repair: () => api.post<{ result: DoctorRepairResult }>('/api/doctor/repair', {}, { timeout: 3_000 }), - reportAndRepair: async (): Promise => { - const [{ report }, { result }] = await Promise.all([ - doctorApi.report(), - doctorApi.repair(), - ]) - - return { - ok: true, - report, - repair: result, - } + report: (cwd?: string) => { + const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' + return api.get<{ report: DoctorReport }>(`/api/doctor/report${query}`, { timeout: 3_000 }) }, } diff --git a/desktop/src/components/doctor/DoctorPanel.tsx b/desktop/src/components/doctor/DoctorPanel.tsx index cb091d86..d536af96 100644 --- a/desktop/src/components/doctor/DoctorPanel.tsx +++ b/desktop/src/components/doctor/DoctorPanel.tsx @@ -1,9 +1,16 @@ -import { Stethoscope } from 'lucide-react' -import { useState } from 'react' -import { Button } from '../shared/Button' +import { RotateCcw, Stethoscope } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import type { DoctorReport, DoctorReportItem } from '../../api/doctor' import { useTranslation } from '../../i18n' -import { runDoctorRepair, type DoctorRepairResult } from '../../lib/doctorRepair' +import { + runDoctorCheck, + runLocalDoctorRepair, + type LocalDoctorRepairResult, +} from '../../lib/doctorRepair' +import { useSessionStore } from '../../stores/sessionStore' import { useUIStore } from '../../stores/uiStore' +import { Button } from '../shared/Button' +import { ConfirmDialog } from '../shared/ConfirmDialog' type DoctorPanelProps = { compact?: boolean @@ -12,32 +19,104 @@ type DoctorPanelProps = { export function DoctorPanel({ compact = false }: DoctorPanelProps) { const t = useTranslation() const addToast = useUIStore((s) => s.addToast) - const [isRunning, setIsRunning] = useState(false) - const [result, setResult] = useState(null) + const activeSessionId = useSessionStore((s) => s.activeSessionId) + const sessions = useSessionStore((s) => s.sessions) + const activeSession = useMemo( + () => sessions.find((session) => session.id === activeSessionId), + [activeSessionId, sessions], + ) + const cwd = activeSession?.workDirExists === false + ? undefined + : activeSession?.workDir ?? activeSession?.projectRoot ?? undefined + const requestSequence = useRef(0) + const cwdRef = useRef(cwd) + cwdRef.current = cwd + const [runningRequestId, setRunningRequestId] = useState(null) + const [resettingRequestId, setResettingRequestId] = useState(null) + const [resetConfirmOpen, setResetConfirmOpen] = useState(false) + const [reportResult, setReportResult] = useState<{ cwd?: string; report: DoctorReport } | null>(null) + const [resetResult, setResetResult] = useState(null) + const report = reportResult && reportResult.cwd === cwd ? reportResult.report : null + + useEffect(() => { + requestSequence.current += 1 + setRunningRequestId(null) + setResettingRequestId(null) + setReportResult(null) + }, [cwd]) + + const beginReportRequest = () => { + const requestId = ++requestSequence.current + const requestCwd = cwd + return { + requestId, + requestCwd, + response: runDoctorCheck({ cwd: requestCwd }), + } + } + + const isCurrentRequest = (requestId: number, requestCwd?: string) => { + return requestSequence.current === requestId && cwdRef.current === requestCwd + } const handleRunDoctor = async () => { - setIsRunning(true) + const request = beginReportRequest() + setResettingRequestId(null) + setRunningRequestId(request.requestId) try { - const nextResult = await runDoctorRepair() - setResult(nextResult) - addToast({ - type: nextResult.local.failedKeys.length === 0 ? 'success' : 'warning', - message: getDoctorToastMessage(t, nextResult), - }) + const nextReport = await request.response + if (!isCurrentRequest(request.requestId, request.requestCwd)) return + setReportResult({ cwd: request.requestCwd, report: nextReport }) + addToast({ type: 'success', message: t('settings.diagnostics.doctorCheckCompleted') }) } catch (error) { + if (!isCurrentRequest(request.requestId, request.requestCwd)) return addToast({ type: 'error', message: error instanceof Error ? error.message : t('settings.diagnostics.doctorFailed'), }) } finally { - setIsRunning(false) + setRunningRequestId((current) => current === request.requestId ? null : current) } } - const statusText = result ? getDoctorStatusMessage(t, result) : null + const handleResetSafeState = async () => { + let requestId: number | null = null + const requestCwd = cwd + try { + const result = runLocalDoctorRepair() + setResetResult(result) + setResetConfirmOpen(false) + addToast({ + type: result.failedKeys.length === 0 ? 'success' : 'warning', + message: result.failedKeys.length === 0 + ? t('settings.diagnostics.doctorResetCompleted') + : t('settings.diagnostics.doctorPartial', { count: String(result.failedKeys.length) }), + }) + const request = beginReportRequest() + requestId = request.requestId + setRunningRequestId(null) + setResettingRequestId(request.requestId) + const nextReport = await request.response + if (!isCurrentRequest(request.requestId, request.requestCwd)) return + setReportResult({ cwd: request.requestCwd, report: nextReport }) + } catch (error) { + if (requestId !== null && !isCurrentRequest(requestId, requestCwd)) return + addToast({ + type: 'error', + message: error instanceof Error ? error.message : t('settings.diagnostics.doctorFailed'), + }) + } finally { + setResettingRequestId((current) => current === requestId ? null : current) + } + } + + const unhealthyItems = report?.items.filter( + (item) => item.status !== 'ok' && item.status !== 'not_configured', + ) ?? [] + const healthyCount = report?.items.filter((item) => item.status === 'ok').length ?? 0 return ( -
+
{t('settings.diagnostics.doctorTitle')}
@@ -48,59 +127,109 @@ export function DoctorPanel({ compact = false }: DoctorPanelProps) { {t('settings.diagnostics.doctorProtectedData')}

-
+
+
{t('settings.diagnostics.doctorSafeKeys')}
+
+ {t('settings.diagnostics.doctorScope')}: {cwd + ? t('settings.diagnostics.doctorScopeProject') + : t('settings.diagnostics.doctorScopeUser')} +
- {statusText ? ( -
- {statusText} + {report ? ( +
+
+ {t('settings.diagnostics.doctorSummary', { + healthy: String(healthyCount), + neutral: String(report.summary.neutralCount), + missing: String(report.summary.missingCount), + invalid: String(report.summary.invalidCount), + })} +
+ {unhealthyItems.length === 0 ? ( +
+ {t('settings.diagnostics.doctorNoFindings')} +
+ ) : ( +
+ {unhealthyItems.map((item) => )} +
+ )}
) : null} + + {resetResult ? ( +
+
{t('settings.diagnostics.doctorRemovedKeys')}: {formatKeys(resetResult.removedKeys, t('settings.diagnostics.doctorNoKeys'))}
+
{t('settings.diagnostics.doctorFailedKeys')}: {formatKeys(resetResult.failedKeys, t('settings.diagnostics.doctorNoKeys'))}
+
+ ) : null} + + { + if (resettingRequestId === null) setResetConfirmOpen(false) + }} + onConfirm={handleResetSafeState} + title={t('settings.diagnostics.resetSafeUiState')} + body={t('settings.diagnostics.confirmResetSafeUiState')} + confirmLabel={t('settings.diagnostics.resetSafeUiState')} + cancelLabel={t('common.cancel')} + confirmVariant="danger" + loading={resettingRequestId !== null} + />
) } -function getDoctorToastMessage( - t: ReturnType, - result: DoctorRepairResult, -): string { - if (result.local.failedKeys.length > 0) { - return t('settings.diagnostics.doctorPartial', { count: String(result.local.failedKeys.length) }) - } - return t('settings.diagnostics.doctorCompleted') +function DoctorFinding({ item }: { item: DoctorReportItem }) { + const t = useTranslation() + return ( +
+
+ {item.path} + + {getStatusLabel(t, item.status)} + +
+ {item.error ?
{item.error}
: null} +
+ ) } -function getDoctorStatusMessage( - t: ReturnType, - result: DoctorRepairResult, -): string { - const clearedCount = result.local.removedKeys.length - const base = t('settings.diagnostics.doctorResultLocal', { count: String(clearedCount) }) - - if (result.local.failedKeys.length > 0) { - return `${base} ${t('settings.diagnostics.doctorResultFailedKeys', { count: String(result.local.failedKeys.length) })}` +function getStatusLabel(t: ReturnType, status: DoctorReportItem['status']): string { + switch (status) { + case 'not_configured': return t('settings.diagnostics.doctorStatusNotConfigured') + case 'missing': return t('settings.diagnostics.doctorStatusMissing') + case 'invalid_json': return t('settings.diagnostics.doctorStatusInvalidJson') + case 'invalid_jsonl': return t('settings.diagnostics.doctorStatusInvalidJsonl') + case 'invalid_schema': return t('settings.diagnostics.doctorStatusInvalidSchema') + case 'unreadable': return t('settings.diagnostics.doctorStatusUnreadable') + default: return t('settings.diagnostics.doctorStatusHealthy') } - - if (result.server) { - return `${base} ${t('settings.diagnostics.doctorServerRan')}` - } - - if (result.serverError) { - return `${base} ${t('settings.diagnostics.doctorServerUnavailable')}` - } - - return base +} + +function formatKeys(keys: string[], emptyLabel: string): string { + return keys.length > 0 ? keys.join(', ') : emptyLabel } diff --git a/desktop/src/components/shared/Toast.test.tsx b/desktop/src/components/shared/Toast.test.tsx new file mode 100644 index 00000000..f93d19a1 --- /dev/null +++ b/desktop/src/components/shared/Toast.test.tsx @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { useSettingsStore } from '../../stores/settingsStore' +import { useUIStore } from '../../stores/uiStore' +import { ToastContainer } from './Toast' + +describe('ToastContainer accessibility', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + useUIStore.setState({ toasts: [] }) + }) + + it('announces success and info messages politely as statuses', () => { + useUIStore.setState({ + toasts: [ + { id: 'success', type: 'success', message: 'Saved' }, + { id: 'info', type: 'info', message: 'Refreshing' }, + ], + }) + + render() + + expect(screen.getByText('Saved').closest('[role]')).toHaveAttribute('role', 'status') + expect(screen.getByText('Saved').closest('[role]')).toHaveAttribute('aria-live', 'polite') + expect(screen.getByText('Refreshing').closest('[role]')).toHaveAttribute('role', 'status') + }) + + it('announces warning and error messages assertively as alerts', () => { + useSettingsStore.setState({ locale: 'zh' }) + useUIStore.setState({ + toasts: [ + { id: 'warning', type: 'warning', message: 'Check settings' }, + { id: 'error', type: 'error', message: 'Save failed' }, + ], + }) + + render() + + expect(screen.getByText('Check settings').closest('[role]')).toHaveAttribute('role', 'alert') + expect(screen.getByText('Check settings').closest('[role]')).toHaveAttribute('aria-live', 'assertive') + expect(screen.getByText('Save failed').closest('[role]')).toHaveAttribute('role', 'alert') + expect(screen.getAllByRole('button', { name: '关闭通知' })).toHaveLength(2) + }) +}) diff --git a/desktop/src/components/shared/Toast.tsx b/desktop/src/components/shared/Toast.tsx index a48b1735..20eae1f8 100644 --- a/desktop/src/components/shared/Toast.tsx +++ b/desktop/src/components/shared/Toast.tsx @@ -1,4 +1,5 @@ import { useUIStore, type Toast as ToastType } from '../../stores/uiStore' +import { useTranslation } from '../../i18n' const typeStyles: Record = { success: 'border-l-4 border-l-[var(--color-success)]', @@ -8,10 +9,15 @@ const typeStyles: Record = { } function ToastItem({ toast }: { toast: ToastType }) { + const t = useTranslation() const removeToast = useUIStore((s) => s.removeToast) + const isUrgent = toast.type === 'warning' || toast.type === 'error' return (
{toast.message}
-
+
- + +
+ {status && status.corruptLineCount > 0 ? ( +
+ {t('settings.diagnostics.corruptLinesWarning', { + count: status.corruptLineCount, + physical: status.physicalLineCount, + })} +
+ ) : null} + + {status?.storageLimitExceeded ? ( +
+ {t('settings.diagnostics.storageLimitExceededWarning')} +
+ ) : null} +
@@ -141,25 +179,29 @@ export function DiagnosticsSettings() {
{status?.logDir ?? '-'}
+ {lastExportPath && ( - + {lastExportPath} )} @@ -183,6 +225,11 @@ export function DiagnosticsSettings() { key={event.id} event={event} detailsLabel={t('settings.diagnostics.eventDetails')} + eventIdLabel={t('settings.diagnostics.eventId')} + copyEventIdLabel={t('settings.diagnostics.copyEventId')} + eventIdCopiedLabel={t('settings.diagnostics.eventIdCopied')} + eventIdCopyFailedLabel={t('settings.diagnostics.eventIdCopyFailed')} + addToast={addToast} /> ))}
@@ -218,9 +265,19 @@ function Metric({ label, value }: { label: string; value: string }) { function EventRow({ event, detailsLabel, + eventIdLabel, + copyEventIdLabel, + eventIdCopiedLabel, + eventIdCopyFailedLabel, + addToast, }: { event: DiagnosticEvent detailsLabel: string + eventIdLabel: string + copyEventIdLabel: string + eventIdCopiedLabel: string + eventIdCopyFailedLabel: string + addToast: ReturnType['addToast'] }) { const severityClass = event.severity === 'error' @@ -231,7 +288,7 @@ function EventRow({ const detailsText = formatDetails(event.details) return ( -
+
{new Date(event.timestamp).toLocaleString()}
@@ -244,6 +301,19 @@ function EventRow({ )}
{event.summary}
+ {detailsText && (
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 5fd53fb0..884ad9b4 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -261,13 +261,14 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri return ( ) diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index d95edeb0..00c4acc7 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -1052,6 +1052,35 @@ describe('ConversationService', () => { expect(completionObserved).toBe(true) expect(service.hasSession(sessionId)).toBe(false) }) + + test('summarizes SDK diagnostics with transport metadata only', () => { + const service = new ConversationService() + const summarized = (service as any).summarizeSdkMessages([{ + type: 'assistant', + subtype: 'api_error', + is_error: true, + status: 'failed', + result: 'PRIVATE_SDK_RESULT', + error: 'PRIVATE_SDK_ERROR', + errorDetails: 'PRIVATE_ERROR_DETAILS', + message: { + content: [{ type: 'text', text: 'PRIVATE_ASSISTANT_REPLY' }], + }, + }]) + + expect(summarized).toEqual([{ + type: 'assistant', + subtype: 'api_error', + is_error: true, + status: 'failed', + errorCategory: 'api_error', + }]) + const serialized = JSON.stringify(summarized) + expect(serialized).not.toContain('PRIVATE_SDK_RESULT') + expect(serialized).not.toContain('PRIVATE_SDK_ERROR') + expect(serialized).not.toContain('PRIVATE_ERROR_DETAILS') + expect(serialized).not.toContain('PRIVATE_ASSISTANT_REPLY') + }) }) function sanitizeMemoryPath(value: string): string { diff --git a/src/server/__tests__/diagnostics-service.test.ts b/src/server/__tests__/diagnostics-service.test.ts index 113f3138..fcf6700b 100644 --- a/src/server/__tests__/diagnostics-service.test.ts +++ b/src/server/__tests__/diagnostics-service.test.ts @@ -62,6 +62,61 @@ async function waitForHttp(url: string, timeoutMs: number): Promise { throw new Error(`Timed out waiting for ${url}${lastError ? ` (${lastError})` : ''}`) } +function readTarEntry(archive: Buffer, entryName: string): string { + const tar = gunzipSync(archive) + let offset = 0 + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512) + if (header.every((byte) => byte === 0)) break + const name = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, '') + const sizeText = header.subarray(124, 136).toString('ascii').replace(/\0.*$/, '').trim() + const size = Number.parseInt(sizeText || '0', 8) + const contentStart = offset + 512 + if (name === entryName) { + return tar.subarray(contentStart, contentStart + size).toString('utf-8') + } + offset = contentStart + Math.ceil(size / 512) * 512 + } + throw new Error(`Missing tar entry: ${entryName}`) +} + +async function assertAppendSurvivesCliCleanup( + service: DiagnosticsService, + appendTarget: string, + marker: string, +): Promise { + await fs.mkdir(service.getLogDir(), { recursive: true }) + await fs.writeFile(appendTarget, 'old cli event\n') + const staleDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) + await fs.utimes(appendTarget, staleDate, staleDate) + const originalRename = fs.rename + const originalRm = fs.rm + let injected = false + const injectAppend = async () => { + if (injected) return + injected = true + await fs.appendFile(appendTarget, `${marker}\n`) + } + const renameSpy = spyOn(fs, 'rename').mockImplementation(async (from, to) => { + if (from === appendTarget) await injectAppend() + return originalRename(from, to) + }) + const rmSpy = spyOn(fs, 'rm').mockImplementation(async (target, options) => { + if (target === appendTarget) await injectAppend() + return originalRm(target, options) + }) + try { + await service.getStatus() + } finally { + renameSpy.mockRestore() + rmSpy.mockRestore() + } + + expect(injected).toBe(true) + const bundle = await service.exportBundle() + expect(readTarEntry(await fs.readFile(bundle.path), 'cli-diagnostics.jsonl')).toContain(marker) +} + describe('DiagnosticsService', () => { test('writes sanitized structured events and runtime error summaries', async () => { const service = new DiagnosticsService() @@ -123,6 +178,154 @@ describe('DiagnosticsService', () => { } }) + test('counts all retained events and reports corrupt lines outside the visible window', async () => { + const service = new DiagnosticsService() + const events = Array.from({ length: 600 }, (_, index) => ({ + id: String(index), + timestamp: new Date().toISOString(), + type: index < 100 ? 'real_failure' : 'normal_exit', + severity: index < 100 ? 'error' : 'info', + summary: index < 100 ? 'boom' : 'clean', + })) + await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true }) + await fs.writeFile(service.getDiagnosticsPath(), `${events.map(JSON.stringify).join('\n')}\n{broken`) + + const status = await service.getStatus() + expect(status.eventCount).toBe(600) + expect(status.recentErrorCount).toBe(100) + expect(status.physicalLineCount).toBe(601) + expect(status.corruptLineCount).toBe(1) + expect(await service.readRecentEvents(100)).toHaveLength(100) + }) + + test('counts blank and invalid event-shaped lines as corrupt physical lines', async () => { + const service = new DiagnosticsService() + const validEvent = { + id: 'valid', + timestamp: new Date().toISOString(), + type: 'valid_event', + severity: 'info', + summary: 'valid', + } + await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true }) + await fs.writeFile(service.getDiagnosticsPath(), [ + JSON.stringify(validEvent), + '', + 'null', + '[]', + JSON.stringify({ ...validEvent, severity: 'fatal' }), + JSON.stringify({ ...validEvent, timestamp: 'not-a-date' }), + JSON.stringify({ ...validEvent, summary: 42 }), + '', + ].join('\n')) + + const status = await service.getStatus() + expect(status.eventCount).toBe(1) + expect(status.physicalLineCount).toBe(7) + expect(status.corruptLineCount).toBe(6) + expect((await service.readRecentEvents()).map((event) => event.id)).toEqual(['valid']) + }) + + test('preserves corrupt-line evidence across the next retention rewrite', async () => { + const service = new DiagnosticsService() + const staleEvent = { + id: 'stale-before-corruption', + timestamp: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(), + type: 'stale', + severity: 'info', + summary: 'old', + } + await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true }) + await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(staleEvent)}\n{PRIVATE_CORRUPT_PAYLOAD\n`) + + await service.recordEvent({ type: 'fresh_after_corruption', summary: 'fresh' }) + + const firstStatus = await service.getStatus() + expect(firstStatus.corruptLineCount).toBe(1) + expect((await service.readRecentEvents()).map((event) => event.type)).toEqual(['fresh_after_corruption']) + expect(await fs.readFile(service.getDiagnosticsPath(), 'utf-8')).not.toContain('PRIVATE_CORRUPT_PAYLOAD') + + await service.recordEvent({ type: 'second_fresh', summary: 'second' }) + expect((await service.getStatus()).corruptLineCount).toBe(1) + }) + + test('does not double count corrupt evidence when the structured rewrite fails and retries', async () => { + const service = new DiagnosticsService() + const validEvent = { + id: 'valid-before-retry', + timestamp: new Date().toISOString(), + type: 'valid', + severity: 'info', + summary: 'valid', + } + await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true }) + await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(validEvent)}\n{broken-once\n`) + const originalRename = fs.rename + let failedRewrite = false + const renameSpy = spyOn(fs, 'rename').mockImplementation(async (from, to) => { + if (!failedRewrite && to === service.getDiagnosticsPath()) { + failedRewrite = true + throw Object.assign(new Error('forced diagnostics rewrite failure'), { code: 'EIO' }) + } + return originalRename(from, to) + }) + try { + await service.recordEvent({ type: 'first_retry_probe', summary: 'first' }) + expect((await service.getStatus()).corruptLineCount).toBe(1) + await service.recordEvent({ type: 'second_retry_probe', summary: 'second' }) + } finally { + renameSpy.mockRestore() + } + + expect((await service.getStatus()).corruptLineCount).toBe(1) + expect((await service.readRecentEvents()).map((event) => event.type)).toContain('second_retry_probe') + }) + + test('recovers pending corrupt evidence after rewrite succeeds but commit fails, then counts new corruption once', async () => { + const service = new DiagnosticsService() + const validEvent = { + id: 'valid-before-commit-crash', + timestamp: new Date().toISOString(), + type: 'valid', + severity: 'info', + summary: 'valid', + } + await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true }) + await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(validEvent)}\n{first-corrupt\n`) + const evidencePath = path.join(service.getLogDir(), 'corruption-evidence.json') + const originalRename = fs.rename + let failedCommit = false + const renameSpy = spyOn(fs, 'rename').mockImplementation(async (from, to) => { + if (!failedCommit && to === evidencePath) { + failedCommit = true + throw Object.assign(new Error('forced evidence commit failure'), { code: 'EIO' }) + } + return originalRename(from, to) + }) + try { + await service.recordEvent({ type: 'rewrite_succeeds_commit_fails', summary: 'first' }) + await fs.appendFile(service.getDiagnosticsPath(), '{second-corrupt\n') + await service.recordEvent({ type: 'retry_after_new_corruption', summary: 'second' }) + } finally { + renameSpy.mockRestore() + } + + expect((await service.getStatus()).corruptLineCount).toBe(2) + expect((await service.readRecentEvents()).map((event) => event.type)).toContain('retry_after_new_corruption') + }) + + test('returns a failed write result when the diagnostics directory cannot be created', async () => { + const service = new DiagnosticsService() + const blockedConfigDir = path.join(tmpDir, 'config-file') + await fs.writeFile(blockedConfigDir, 'not-a-directory') + process.env.CLAUDE_CONFIG_DIR = blockedConfigDir + + const result = await service.recordEvent({ type: 'write_probe', severity: 'error', summary: 'boom' }) + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).not.toContain(tmpDir) + }) + test('exports a single diagnostics tarball without provider secrets', async () => { const service = new DiagnosticsService() await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) @@ -135,7 +338,7 @@ describe('DiagnosticsService', () => { name: 'Test Provider', presetId: 'custom', apiKey: 'sk-provider-secret', - baseUrl: 'https://api.example.com/anthropic', + baseUrl: 'https://user:pass@api.example.com/private?token=x', apiFormat: 'anthropic', models: { main: 'main-model', haiku: 'haiku-model', sonnet: 'sonnet-model', opus: 'opus-model' }, }], @@ -166,10 +369,384 @@ describe('DiagnosticsService', () => { expect(archiveText).toContain('cli_streaming_idle_timeout') expect(archiveText).toContain('Test Provider') expect(archiveText).toContain('api.example.com') + expect(archiveText).toContain('projected diagnostic metadata/details') + expect(archiveText).not.toContain('captured runtime details') + expect(archiveText).not.toContain('user:pass') + expect(archiveText).not.toContain('/private') + expect(archiveText).not.toContain('token=x') expect(archiveText).not.toContain('sk-provider-secret') expect(archiveText).not.toContain('provider-secret') }) + test('leaves exported runtime-errors.log empty when only info events exist', async () => { + const service = new DiagnosticsService() + await service.recordEvent({ + type: 'informational_probe', + severity: 'info', + summary: 'routine status', + }) + + const bundle = await service.exportBundle() + + expect(readTarEntry(await fs.readFile(bundle.path), 'runtime-errors.log')).toBe('') + }) + + test('exports share projections without captured SDK or assistant content', async () => { + const service = new DiagnosticsService() + await service.recordEvent({ + type: 'sdk_api_error', + severity: 'error', + summary: 'PRIVATE_ASSISTANT_REPLY', + details: { + errorCode: 'API_ERROR', + status: 'failed', + capturedOutput: 'PRIVATE_CAPTURED_OUTPUT', + sdkMessages: [{ result: 'PRIVATE_SDK_RESULT' }], + bareToken: 'sk-ant-api03-BARESECRET', + }, + }) + + const bundle = await service.exportBundle() + const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8') + + expect(archiveText).toContain('event') + expect(archiveText).toContain('omittedFields') + expect(archiveText).toContain('API_ERROR') + expect(archiveText).not.toContain('PRIVATE_ASSISTANT_REPLY') + expect(archiveText).not.toContain('PRIVATE_CAPTURED_OUTPUT') + expect(archiveText).not.toContain('PRIVATE_SDK_RESULT') + expect(archiveText).not.toContain('sk-ant-api03-BARESECRET') + }) + + test('bounds shared event payloads and issue-report event ids to recent evidence', async () => { + const service = new DiagnosticsService() + const events = Array.from({ length: 5_100 }, (_, index) => ({ + id: `event-${index}`, + timestamp: new Date(Date.now() + index).toISOString(), + type: 'bounded_export_probe', + severity: 'error', + summary: `private summary ${index}`, + })) + await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true }) + await fs.writeFile(service.getDiagnosticsPath(), `${events.map(JSON.stringify).join('\n')}\n`) + + const bundle = await service.exportBundle() + const exportedEvents = readTarEntry(await fs.readFile(bundle.path), 'diagnostics.jsonl') + .trim().split('\n').filter(Boolean) + expect(exportedEvents).toHaveLength(5_000) + expect(exportedEvents[0]).toContain('event-5099') + expect(exportedEvents.at(-1)).toContain('event-100') + expect(exportedEvents.join('\n')).not.toContain('event-99"') + + const report = await service.buildIssueReport() + expect(report).toContain('event-5099') + expect(report).not.toContain('event-4999') + const eventIdsLine = report.split('\n').find((line) => line.startsWith('- Event IDs:')) ?? '' + expect(eventIdsLine.length).toBeLessThan(8_000) + }) + + test('includes a sanitized bounded Electron host log in status and exports', async () => { + const service = new DiagnosticsService() + const electronHostPath = path.join(service.getLogDir(), 'electron-host.log') + await fs.mkdir(service.getLogDir(), { recursive: true }) + await fs.writeFile( + electronHostPath, + `${'old host line\n'.repeat(500_000)}latest failure token=ELECTRON_SECRET /Users/alice/private/project\n`, + ) + + const status = await service.getStatus() + expect(status.electronHostPath).toBe(electronHostPath) + expect((await fs.stat(electronHostPath)).size).toBeLessThanOrEqual(5 * 1024 * 1024) + + const bundle = await service.exportBundle() + const hostLog = readTarEntry(await fs.readFile(bundle.path), 'electron-host.log') + expect(hostLog).toContain('latest failure') + expect(hostLog).not.toContain('ELECTRON_SECRET') + expect(hostLog).not.toContain('/Users/alice') + expect(Buffer.byteLength(hostLog)).toBeLessThanOrEqual(256 * 1024 + 64) + }) + + test('bounds active diagnostic surfaces and recent exports under the advertised directory cap', async () => { + const service = new DiagnosticsService() + await fs.mkdir(service.getExportDir(), { recursive: true }) + const oversized = `${'diagnostic line\n'.repeat(450_000)}latest marker\n` + const closedCliSegment = `${service.getCliDiagnosticsPath()}.123.1.jsonl` + await Promise.all([ + fs.writeFile(service.getRuntimeErrorsPath(), oversized), + fs.writeFile(closedCliSegment, oversized), + fs.writeFile(path.join(service.getLogDir(), 'electron-host.log'), oversized), + ...Array.from({ length: 20 }, (_, index) => fs.writeFile( + path.join(service.getExportDir(), `recent-${String(index).padStart(2, '0')}.tar.gz`), + Buffer.alloc(1024 * 1024, index), + )), + ]) + + const status = await service.getStatus() + expect(status.totalBytes).toBeLessThanOrEqual(status.maxBytes) + for (const filePath of [ + service.getRuntimeErrorsPath(), + path.join(service.getLogDir(), 'electron-host.log'), + ]) { + expect((await fs.stat(filePath)).size).toBeLessThanOrEqual(5 * 1024 * 1024) + } + await expect(fs.stat(closedCliSegment)).rejects.toThrow() + const exports = await fs.readdir(service.getExportDir()) + const exportStats = await Promise.all(exports.map((name) => fs.stat(path.join(service.getExportDir(), name)))) + expect(exportStats.reduce((sum, stat) => sum + stat.size, 0)).toBeLessThanOrEqual(15 * 1024 * 1024) + }) + + test('caps closed CLI segments globally and reports active multiprocess overflow honestly', async () => { + const service = new DiagnosticsService() + await fs.mkdir(service.getLogDir(), { recursive: true }) + await Promise.all([ + ...Array.from({ length: 8 }, (_, index) => fs.writeFile( + `${service.getCliDiagnosticsPath()}.${index}.closed.jsonl`, + Buffer.alloc(1024 * 1024, index), + )), + ...Array.from({ length: 55 }, (_, index) => fs.writeFile( + `${service.getCliDiagnosticsPath()}.unknown-${index}.current.jsonl`, + Buffer.alloc(1024 * 1024, index), + )), + ]) + + const status = await service.getStatus() + const files = await fs.readdir(service.getLogDir()) + const closed = files.filter((name) => name.includes('.closed.jsonl')) + const closedStats = await Promise.all(closed.map((name) => fs.stat(path.join(service.getLogDir(), name)))) + expect(closedStats.reduce((sum, stat) => sum + stat.size, 0)).toBeLessThanOrEqual(5 * 1024 * 1024) + expect(status.totalBytes).toBeGreaterThan(status.maxBytes) + expect(status.storageLimitExceeded).toBe(true) + }) + + test('reclaims dead current CLI segments but preserves the live current process segment', async () => { + const service = new DiagnosticsService() + await fs.mkdir(service.getLogDir(), { recursive: true }) + const livePath = `${service.getCliDiagnosticsPath()}.${process.pid}.current.jsonl` + const deadPath = `${service.getCliDiagnosticsPath()}.99999999.current.jsonl` + await fs.writeFile(livePath, 'live-current\n') + await fs.writeFile(deadPath, 'dead-current\n') + + await service.getStatus() + + await expect(fs.readFile(livePath, 'utf-8')).resolves.toContain('live-current') + await expect(fs.stat(deadPath)).rejects.toThrow() + + const staleDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) + await fs.utimes(livePath, staleDate, staleDate) + await service.getStatus() + await expect(fs.stat(livePath)).rejects.toThrow() + }) + + test('reclaims stale legacy and unknown-PID current files by retention lease while preserving recent legacy', async () => { + const service = new DiagnosticsService() + await fs.mkdir(service.getLogDir(), { recursive: true }) + const legacyPath = service.getCliDiagnosticsPath() + const unknownCurrentPath = `${service.getCliDiagnosticsPath()}.not-a-pid.current.jsonl` + await fs.writeFile(legacyPath, 'stale legacy\n') + await fs.writeFile(unknownCurrentPath, 'stale unknown pid\n') + const staleDate = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000) + await fs.utimes(legacyPath, staleDate, staleDate) + await fs.utimes(unknownCurrentPath, staleDate, staleDate) + + await service.getStatus() + + await expect(fs.stat(legacyPath)).rejects.toThrow() + await expect(fs.stat(unknownCurrentPath)).rejects.toThrow() + await fs.writeFile(legacyPath, Buffer.alloc(6 * 1024 * 1024, 1)) + const recentLegacyStatus = await service.getStatus() + await expect(fs.stat(legacyPath)).resolves.toBeTruthy() + expect(recentLegacyStatus.storageLimitExceeded).toBe(true) + await fs.utimes(legacyPath, staleDate, staleDate) + const reclaimedStatus = await service.getStatus() + await expect(fs.stat(legacyPath)).rejects.toThrow() + expect(reclaimedStatus.storageLimitExceeded).toBe(true) + const settledStatus = await service.getStatus() + expect(settledStatus.storageLimitExceeded).toBe(false) + }) + + test('never replaces an active legacy CLI diagnostics file during retention', async () => { + const service = new DiagnosticsService() + const cliPath = service.getCliDiagnosticsPath() + await fs.mkdir(path.dirname(cliPath), { recursive: true }) + await fs.writeFile(cliPath, `${'old cli line\n'.repeat(500_000)}before-retention\n`) + const originalRename = fs.rename + let cliRenameAttempted = false + const renameSpy = spyOn(fs, 'rename').mockImplementation(async (from, to) => { + if (to === cliPath) cliRenameAttempted = true + return originalRename(from, to) + }) + try { + await Promise.all([ + service.getStatus(), + fs.appendFile(cliPath, 'append-during-retention\n'), + ]) + } finally { + renameSpy.mockRestore() + } + + expect(cliRenameAttempted).toBe(false) + expect(await fs.readFile(cliPath, 'utf-8')).toContain('append-during-retention') + }) + + test('re-lists CLI segments when the writer rotates between export listing and reading', async () => { + const service = new DiagnosticsService() + await fs.mkdir(service.getLogDir(), { recursive: true }) + const currentPath = `${service.getCliDiagnosticsPath()}.${process.pid}.current.jsonl` + const completedPath = `${service.getCliDiagnosticsPath()}.${process.pid}.rotated.jsonl` + await fs.writeFile(currentPath, '{"event":"ROTATED_BETWEEN_LIST_AND_READ"}\n') + const originalOpen = fs.open + let rotated = false + const openSpy = spyOn(fs, 'open').mockImplementation(async (filePath, ...args) => { + if (!rotated && filePath === currentPath) { + rotated = true + await fs.rename(currentPath, completedPath) + } + return originalOpen(filePath, ...args) + }) + try { + const bundle = await service.exportBundle() + const cliLog = readTarEntry(await fs.readFile(bundle.path), 'cli-diagnostics.jsonl') + expect(cliLog).toContain('ROTATED_BETWEEN_LIST_AND_READ') + } finally { + openSpy.mockRestore() + } + }) + + test('quarantines a stale PID-current segment so an append immediately before cleanup survives', async () => { + const service = new DiagnosticsService() + const currentPath = `${service.getCliDiagnosticsPath()}.${process.pid}.current.jsonl` + await assertAppendSurvivesCliCleanup(service, currentPath, 'PID_CURRENT_NEW_EVENT') + }) + + test('quarantines a stale legacy append target so an append immediately before cleanup survives', async () => { + const service = new DiagnosticsService() + await assertAppendSurvivesCliCleanup(service, service.getCliDiagnosticsPath(), 'LEGACY_NEW_EVENT') + }) + + test('compacts stale structured events without deleting the active diagnostics file', async () => { + const service = new DiagnosticsService() + const staleEvent = { + id: 'stale', + timestamp: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(), + type: 'stale', + severity: 'info', + summary: 'old', + } + await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true }) + await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(staleEvent)}\n`) + + await service.recordEvent({ type: 'fresh', severity: 'info', summary: 'new' }) + + expect((await service.readRecentEvents()).map((event) => event.type)).toEqual(['fresh']) + await expect(fs.stat(service.getDiagnosticsPath())).resolves.toBeTruthy() + }) + + test('keeps the newest complete structured lines when the active file exceeds the byte cap', async () => { + const service = new DiagnosticsService() + const oversizedSummary = 'x'.repeat(51 * 1024 * 1024) + const oversizedEvent = { + id: 'oversized', + timestamp: new Date().toISOString(), + type: 'oversized', + severity: 'info', + summary: oversizedSummary, + } + await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true }) + await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(oversizedEvent)}\n`) + + await service.recordEvent({ type: 'fresh', severity: 'info', summary: 'new' }) + + expect((await service.readRecentEvents()).map((event) => event.type)).toEqual(['fresh']) + await expect(fs.stat(service.getDiagnosticsPath())).resolves.toBeTruthy() + }) + + test('serializes concurrent writes with forced compaction so neither event is lost', async () => { + const service = new DiagnosticsService() + const oversizedEvent = { + id: 'oversized', + timestamp: new Date().toISOString(), + type: 'oversized', + severity: 'info', + summary: 'x'.repeat(51 * 1024 * 1024), + } + await fs.mkdir(path.dirname(service.getDiagnosticsPath()), { recursive: true }) + await fs.writeFile(service.getDiagnosticsPath(), `${JSON.stringify(oversizedEvent)}\n`) + + let releaseFirstRename!: () => void + let firstRenameReached!: () => void + const firstRenameGate = new Promise((resolve) => { releaseFirstRename = resolve }) + const firstRenameSignal = new Promise((resolve) => { firstRenameReached = resolve }) + const originalRename = fs.rename + let renameCount = 0 + const renameSpy = spyOn(fs, 'rename').mockImplementation(async (...args) => { + renameCount += 1 + if (renameCount === 1) { + firstRenameReached() + await firstRenameGate + return originalRename(...args) + } + return originalRename(...args) + }) + + try { + const firstWrite = service.recordEvent({ type: 'concurrent_first', summary: 'first' }) + await firstRenameSignal + const secondWrite = service.recordEvent({ type: 'concurrent_second', summary: 'second' }) + await Bun.sleep(100) + expect(renameCount).toBe(1) + releaseFirstRename() + await Promise.all([firstWrite, secondWrite]) + } finally { + releaseFirstRename() + renameSpy.mockRestore() + } + + expect((await service.readRecentEvents()).map((event) => event.type)).toEqual([ + 'concurrent_second', + 'concurrent_first', + ]) + }) + + test('exports the tail of oversized CLI text logs', async () => { + const service = new DiagnosticsService() + const cliPath = service.getCliDiagnosticsPath() + await fs.mkdir(path.dirname(cliPath), { recursive: true }) + await fs.writeFile( + cliPath, + `OLDEST_PREFIX_MARKER\n${'routine diagnostic line\n'.repeat(15_000)}LATEST_FAILURE_MARKER\n`, + ) + + const bundle = await service.exportBundle() + const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8') + + expect(archiveText).toContain('[TRUNCATED OLDER CONTENT]') + expect(archiveText).toContain('LATEST_FAILURE_MARKER') + expect(archiveText).not.toContain('OLDEST_PREFIX_MARKER') + }) + + test('redacts a secret whose key is split across the tail-read byte boundary', async () => { + const service = new DiagnosticsService() + const cliPath = service.getCliDiagnosticsPath() + const secret = 'BOUNDARY_SPLIT_SECRET' + const marker = 'LATEST_FAILURE_MARKER\n' + const suffixWithoutKey = `${secret}\n\n${marker}` + const paddingBytes = 256 * 1024 - Buffer.byteLength(suffixWithoutKey) + const safeLine = 'safe line\n' + const suffixPadding = safeLine.repeat(Math.floor(paddingBytes / Buffer.byteLength(safeLine))) + + 'z'.repeat(paddingBytes % Buffer.byteLength(safeLine)) + await fs.mkdir(path.dirname(cliPath), { recursive: true }) + await fs.writeFile( + cliPath, + `${'old-prefix'.repeat(600)}\ntoken=${secret}\n${suffixPadding}\n${marker}`, + ) + + const bundle = await service.exportBundle() + const archiveText = gunzipSync(await fs.readFile(bundle.path)).toString('utf-8') + + expect(archiveText).toContain('LATEST_FAILURE_MARKER') + expect(archiveText).not.toContain(secret) + }) + test('keeps fatal startup errors visible on stderr while recording diagnostics', async () => { const port = await getPort() const serverArgs = ['bun', '--no-env-file', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(port)] @@ -257,6 +834,9 @@ describe('diagnostics API', () => { clientEventUrl.pathname.split('/').filter(Boolean), ) expect(clientEventRes.status).toBe(200) + const clientEventBody = await clientEventRes.json() as { ok: boolean; eventId: string } + expect(clientEventBody.ok).toBe(true) + expect(clientEventBody.eventId).toBeString() const clientEvents = await service.readRecentEvents(10) expect(clientEvents[0].type).toBe('client_unhandled_rejection') expect(JSON.stringify(clientEvents[0])).toContain('[REDACTED]') @@ -273,4 +853,76 @@ describe('diagnostics API', () => { expect(clearRes.status).toBe(200) expect(await service.readRecentEvents()).toEqual([]) }) + + test('returns a server error when a client diagnostic event cannot be written', async () => { + const recordSpy = spyOn(diagnosticsService, 'recordEvent').mockResolvedValue({ + ok: false, + error: 'diagnostic write failed', + }) + try { + const request = new Request('http://localhost:3456/api/diagnostics/events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'write_probe', summary: 'boom' }), + }) + const url = new URL(request.url) + + const response = await handleDiagnosticsApi( + request, + url, + url.pathname.split('/').filter(Boolean), + ) + + expect(response.status).toBeGreaterThanOrEqual(500) + } finally { + recordSpy.mockRestore() + } + }) + + test('returns a deterministic share-safe GitHub issue report', async () => { + await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) + await fs.writeFile( + path.join(tmpDir, 'cc-haha', 'providers.json'), + JSON.stringify({ + activeId: 'provider-issue-report', + providers: [{ + id: 'provider-issue-report', + name: 'Issue Report Provider', + apiFormat: 'anthropic', + baseUrl: 'https://user:pass@api.example.com/private?token=x', + models: { main: 'main-model' }, + }], + }), + 'utf-8', + ) + await diagnosticsService.recordEvent({ + type: 'sdk_result_error', + severity: 'error', + summary: 'PRIVATE_ASSISTANT_REPLY', + details: { + errorCode: 'CLI_EXITED', + status: 'failed', + sdkMessages: [{ result: 'PRIVATE_SDK_RESULT' }], + }, + }) + const request = makeRequest('GET', '/api/diagnostics/issue-report') + + const response = await handleDiagnosticsApi(request.req, request.url, request.segments) + const body = await response.json() as { report: string } + + expect(response.status).toBe(200) + expect(body.report).toContain('## 问题描述') + expect(body.report).toContain('## 运行环境') + expect(body.report).toContain('## Provider / 模型') + expect(body.report).toContain('## 诊断关联') + expect(body.report).toContain('## 复现步骤') + expect(body.report).toContain('## 错误摘要') + expect(body.report).toContain('CLI_EXITED') + expect(body.report).toContain('api.example.com') + expect(body.report).not.toContain('user:pass') + expect(body.report).not.toContain('/private') + expect(body.report).not.toContain('token=x') + expect(body.report).not.toContain('PRIVATE_ASSISTANT_REPLY') + expect(body.report).not.toContain('PRIVATE_SDK_RESULT') + }) }) diff --git a/src/server/__tests__/doctor-service.test.ts b/src/server/__tests__/doctor-service.test.ts index 2333c103..4fd47fb1 100644 --- a/src/server/__tests__/doctor-service.test.ts +++ b/src/server/__tests__/doctor-service.test.ts @@ -72,6 +72,71 @@ function makeRequest( } describe('DoctorService', () => { + test('treats absent optional user features on a fresh install as not configured', async () => { + const freshHomeDir = path.join(tmpDir, 'fresh-home') + const freshConfigDir = path.join(freshHomeDir, '.claude') + const service = new DoctorService({ configDir: freshConfigDir, homeDir: freshHomeDir }) + + const report = await service.getReport() + + expect(report.items.length).toBeGreaterThan(0) + expect(report.items.every((item) => item.status === 'not_configured')).toBe(true) + expect(report.summary).toEqual(expect.objectContaining({ + total: report.items.length, + neutralCount: report.items.length, + missingCount: 0, + invalidCount: 0, + })) + }) + + test('treats absent optional project settings, skills, and MCP as not configured', async () => { + const freshProjectRoot = path.join(tmpDir, 'fresh-project') + const service = new DoctorService({ configDir, homeDir, projectRoot: freshProjectRoot }) + + const report = await service.getReport() + const projectItems = report.items.filter((item) => item.scope === 'project') + + expect(projectItems.map((item) => item.id)).toEqual([ + 'project-settings', + 'project-skills', + 'project-mcp', + ]) + expect(projectItems.every((item) => item.status === 'not_configured')).toBe(true) + expect(report.summary.neutralCount).toBeGreaterThanOrEqual(3) + }) + + test('still reports a configured optional feature when its file is malformed', async () => { + await fs.writeFile(path.join(configDir, 'adapters.json'), '{broken', 'utf-8') + const service = new DoctorService({ configDir, homeDir }) + + const report = await service.getReport() + const adapters = report.items.find((item) => item.id === 'adapters') + + expect(adapters?.status).toBe('invalid_json') + expect(report.summary.invalidCount).toBeGreaterThanOrEqual(1) + expect(report.summary.neutralCount).toBeGreaterThan(0) + }) + + test('reports schema-invalid managed providers without exposing parsed contents', async () => { + const schemaConfigDir = path.join(homeDir, '.schema-test-claude') + await fs.mkdir(path.join(schemaConfigDir, 'cc-haha'), { recursive: true }) + await fs.writeFile( + path.join(schemaConfigDir, 'cc-haha', 'providers.json'), + JSON.stringify({ activeId: null, providers: [{ id: 'provider-1' }] }), + 'utf-8', + ) + const service = new DoctorService({ configDir: schemaConfigDir, homeDir }) + + const report = await service.getReport() + const providers = report.items.find((item) => item.id === 'cc-haha-providers') + + expect(providers?.status).toBe('invalid_schema') + expect(providers?.error).toContain('providers.0.presetId') + expect(providers?.error).not.toContain('provider-1') + expect(providers?.error).not.toContain(tmpDir) + expect(report.summary.invalidCount).toBe(1) + }) + test('report redacts filesystem paths and lists protected skipped items', async () => { const service = new DoctorService({ configDir, homeDir, projectRoot }) diff --git a/src/server/api/diagnostics.ts b/src/server/api/diagnostics.ts index a1e3d240..c1e95385 100644 --- a/src/server/api/diagnostics.ts +++ b/src/server/api/diagnostics.ts @@ -3,6 +3,7 @@ * * GET /api/diagnostics/status — log directory, retention and counters * GET /api/diagnostics/events — recent sanitized diagnostic events + * GET /api/diagnostics/issue-report — share-safe GitHub issue report Markdown * POST /api/diagnostics/events — append a sanitized client diagnostic event * POST /api/diagnostics/export — write a sanitized tar.gz bundle * POST /api/diagnostics/open-log-dir — open the diagnostics directory @@ -35,6 +36,10 @@ export async function handleDiagnosticsApi( return Response.json({ events }) } + if (action === 'issue-report' && req.method === 'GET') { + return Response.json({ report: await diagnosticsService.buildIssueReport() }) + } + if (action === 'events' && req.method === 'POST') { const body = await parseJsonBody(req) const type = typeof body.type === 'string' && body.type.trim() @@ -46,14 +51,15 @@ export async function handleDiagnosticsApi( ? body.summary : type const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined - await diagnosticsService.recordEvent({ + const result = await diagnosticsService.recordEvent({ type, severity, summary, sessionId, details: body.details, }) - return Response.json({ ok: true }) + if (!result.ok) throw ApiError.internal(result.error) + return Response.json({ ok: true, eventId: result.event.id }) } if (action === 'export' && req.method === 'POST') { diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 49b596ee..3543dece 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -1680,38 +1680,31 @@ export class ConversationService { private summarizeSdkMessages(messages: any[]): unknown[] { return messages.slice(-MAX_CAPTURED_SDK_SUMMARY).map((message) => { if (!message || typeof message !== 'object') { - return message + return { type: 'unknown' } } - const content = Array.isArray(message.message?.content) - ? message.message.content.map((block: unknown) => { - if (!block || typeof block !== 'object') return block - const typedBlock = block as Record - return { - type: typedBlock.type, - text: - typeof typedBlock.text === 'string' - ? this.redactProcessOutput(typedBlock.text) - : undefined, - } - }) - : undefined 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' ? this.redactProcessOutput(message.result) : undefined, - error: typeof message.error === 'string' ? this.redactProcessOutput(message.error) : undefined, - errorDetails: - typeof message.errorDetails === 'string' - ? this.redactProcessOutput(message.errorDetails) - : undefined, - message: typeof message.message === 'string' ? this.redactProcessOutput(message.message) : undefined, - content, + type: typeof message.type === 'string' ? message.type : 'unknown', + ...(typeof message.subtype === 'string' ? { subtype: message.subtype } : {}), + ...(typeof message.is_error === 'boolean' ? { is_error: message.is_error } : {}), + ...(this.isSafeSdkStatus(message.status) ? { status: message.status } : {}), + ...(this.sdkErrorCategory(message) ? { errorCategory: this.sdkErrorCategory(message) } : {}), } }) } + private isSafeSdkStatus(value: unknown): value is string { + return typeof value === 'string' && /^(?:failed|error|success|completed|cancelled|canceled|pending|running)$/i.test(value) + } + + private sdkErrorCategory(message: any): string | undefined { + if (message?.type === 'assistant' && (message.isApiErrorMessage === true || message.error !== undefined)) { + return 'api_error' + } + if (message?.type === 'result' && message.is_error === true) return 'result_error' + if (message?.type === 'auth_status') return 'authentication' + return undefined + } + private async buildUserContent( content: string, sessionId: string, diff --git a/src/server/services/diagnosticsService.ts b/src/server/services/diagnosticsService.ts index 77270ebd..b7fc55c7 100644 --- a/src/server/services/diagnosticsService.ts +++ b/src/server/services/diagnosticsService.ts @@ -1,8 +1,14 @@ import * as fs from 'node:fs/promises' +import { createHash } from 'node:crypto' import * as os from 'node:os' import * as path from 'node:path' import { gzipSync } from 'node:zlib' import type { Dirent } from 'node:fs' +import { + buildDiagnosticsIssueReport, + projectDiagnosticEventForSharing, + type SharedDiagnosticEvent, +} from './diagnosticsShare.js' export type DiagnosticSeverity = 'debug' | 'info' | 'warn' | 'error' @@ -29,15 +35,38 @@ export type DiagnosticsStatus = { diagnosticsPath: string cliDiagnosticsPath: string runtimeErrorsPath: string + electronHostPath: string exportDir: string retentionDays: number maxBytes: number totalBytes: number + storageLimitExceeded: boolean eventCount: number + physicalLineCount: number + corruptLineCount: number recentErrorCount: number lastEventAt: string | null } +export type DiagnosticWriteResult = + | { ok: true; event: DiagnosticEvent } + | { ok: false; error: string } + +type DiagnosticsScanResult = { + events: DiagnosticEvent[] + physicalLineCount: number + corruptLineCount: number + rawCorruptLineCount: number + sourceBytes: number + sourceDigest: string +} + +type PendingCorruptionEvidence = { + corruptLineCount: number + sourceBytes: number + sourceDigest: string +} + export type DiagnosticsExportResult = { path: string fileName: string @@ -46,11 +75,18 @@ export type DiagnosticsExportResult = { const RETENTION_DAYS = 7 const MAX_BYTES = 50 * 1024 * 1024 +const MAX_DIAGNOSTICS_BYTES = 20 * 1024 * 1024 +const MAX_AUXILIARY_LOG_BYTES = 5 * 1024 * 1024 +const MAX_CLI_COMPLETED_SEGMENTS_BYTES = 5 * 1024 * 1024 +const MAX_EXPORT_DIRECTORY_BYTES = 14 * 1024 * 1024 +const MAX_SHARED_EVENTS = 5_000 +const MAX_ISSUE_REPORT_EVENTS = 100 const MAX_STRING_LENGTH = 4096 const MAX_TEXT_FILE_EXPORT_LENGTH = 256 * 1024 const MAX_ARRAY_ITEMS = 40 const MAX_OBJECT_KEYS = 80 -const MAX_EVENTS_IN_EXPORT = 5000 +const RETENTION_SWEEP_INTERVAL_MS = 60 * 1000 +const TRUNCATED_OLDER_CONTENT_MARKER = '[TRUNCATED OLDER CONTENT]\n' 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 { @@ -58,6 +94,8 @@ export class DiagnosticsService { private processCaptureInstalled = false private originalConsoleError: typeof console.error | null = null private originalConsoleWarn: typeof console.warn | null = null + private lastRetentionSweepAt = 0 + private writeQueue: Promise = Promise.resolve() getLogDir(): string { return path.join(this.getConfigDir(), 'cc-haha', 'diagnostics') @@ -75,17 +113,31 @@ export class DiagnosticsService { return path.join(this.getLogDir(), 'runtime-errors.log') } + getElectronHostPath(): string { + return path.join(this.getLogDir(), 'electron-host.log') + } + + private getCorruptionEvidencePath(): string { + return path.join(this.getLogDir(), 'corruption-evidence.json') + } + + private getPendingCorruptionEvidencePath(): string { + return path.join(this.getLogDir(), 'corruption-evidence.pending.json') + } + getExportDir(): string { return path.join(this.getLogDir(), 'exports') } - async recordEvent(input: DiagnosticEventInput): Promise { + async recordEvent(input: DiagnosticEventInput): Promise { // Test isolation: never let a test run write into the user's real // ~/.claude/cc-haha/diagnostics. Tests that genuinely exercise diagnostics // set CLAUDE_CONFIG_DIR to a tmp dir; anything else under NODE_ENV=test is // a leak (e.g. a fire-and-forget recordEvent resolving after a test's // afterEach restored CLAUDE_CONFIG_DIR) and must be dropped. - if (process.env.NODE_ENV === 'test' && !process.env.CLAUDE_CONFIG_DIR) return + if (process.env.NODE_ENV === 'test' && !process.env.CLAUDE_CONFIG_DIR) { + return { ok: false, error: 'Diagnostics are disabled without an isolated config directory during tests' } + } const event: DiagnosticEvent = { id: crypto.randomUUID(), @@ -99,6 +151,12 @@ export class DiagnosticsService { ...(input.details !== undefined ? { details: this.sanitizeValue(input.details) } : {}), } + const operation = this.writeQueue.then(() => this.writeEvent(event)) + this.writeQueue = operation.then(() => undefined, () => undefined) + return operation + } + + private async writeEvent(event: DiagnosticEvent): Promise { try { await this.ensureLogDir() await fs.appendFile(this.getDiagnosticsPath(), JSON.stringify(event) + '\n', 'utf-8') @@ -106,8 +164,10 @@ export class DiagnosticsService { await fs.appendFile(this.getRuntimeErrorsPath(), this.formatRuntimeLogEntry(event), 'utf-8') } await this.enforceRetention().catch(() => {}) - } catch { + return { ok: true, event } + } catch (error) { // Diagnostics must never break the product path. + return { ok: false, error: this.sanitizeWriteError(error) } } } @@ -183,7 +243,9 @@ export class DiagnosticsService { async getStatus(): Promise { await this.ensureLogDir() - const events = await this.readRecentEvents(500) + await this.runRetentionSerialized(true, false) + const scan = await this.scanDiagnosticsFile() + const events = scan.events const totalBytes = await this.getDirectorySize(this.getLogDir()) const cutoff = Date.now() - 24 * 60 * 60 * 1000 return { @@ -191,50 +253,85 @@ export class DiagnosticsService { diagnosticsPath: this.getDiagnosticsPath(), cliDiagnosticsPath: this.getCliDiagnosticsPath(), runtimeErrorsPath: this.getRuntimeErrorsPath(), + electronHostPath: this.getElectronHostPath(), exportDir: this.getExportDir(), retentionDays: RETENTION_DAYS, maxBytes: MAX_BYTES, totalBytes, + storageLimitExceeded: totalBytes > MAX_BYTES || await this.isManagedSurfaceOverLimit(), eventCount: events.length, + physicalLineCount: scan.physicalLineCount, + corruptLineCount: scan.corruptLineCount, recentErrorCount: events.filter((event) => (event.severity === 'error' || event.severity === 'warn') && Date.parse(event.timestamp) >= cutoff ).length, - lastEventAt: events[0]?.timestamp ?? null, + lastEventAt: events.at(-1)?.timestamp ?? null, } } async readRecentEvents(limit = 100): Promise { const boundedLimit = Math.max(1, Math.min(limit, 1000)) + const scan = await this.scanDiagnosticsFile() + return scan.events.slice(-boundedLimit).reverse() + } + + private async scanDiagnosticsFile(): Promise { let raw = '' try { raw = await fs.readFile(this.getDiagnosticsPath(), 'utf-8') } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + const persistedCorruptLineCount = await this.readPersistedCorruptLineCount() + return { + events: [], + physicalLineCount: 0, + corruptLineCount: persistedCorruptLineCount, + rawCorruptLineCount: 0, + sourceBytes: 0, + sourceDigest: this.hashBuffer(Buffer.alloc(0)), + } + } throw err } - return raw - .split('\n') - .filter(Boolean) - .slice(-boundedLimit) - .map((line) => { - try { - return JSON.parse(line) as DiagnosticEvent - } catch { - return null + const lines = raw.length === 0 ? [] : raw.split('\n') + if (raw.endsWith('\n')) lines.pop() + const events: DiagnosticEvent[] = [] + let corruptLineCount = 0 + for (const line of lines) { + try { + const parsed = JSON.parse(line) as unknown + if (!this.isDiagnosticEvent(parsed)) { + corruptLineCount += 1 + continue } - }) - .filter((event): event is DiagnosticEvent => event !== null) - .reverse() + events.push(parsed) + } catch { + corruptLineCount += 1 + } + } + const sourceBuffer = Buffer.from(raw, 'utf-8') + const persistedCorruptLineCount = await this.readPersistedCorruptLineCount(sourceBuffer, corruptLineCount) + return { + events, + physicalLineCount: lines.length, + corruptLineCount: persistedCorruptLineCount + corruptLineCount, + rawCorruptLineCount: corruptLineCount, + sourceBytes: sourceBuffer.byteLength, + sourceDigest: this.hashBuffer(sourceBuffer), + } } async exportBundle(): Promise { await this.ensureLogDir() + await this.runRetentionSerialized(true, true) 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 scan = await this.scanDiagnosticsFile() + const events = scan.events.slice(-MAX_SHARED_EVENTS).reverse() + const sharedEvents = events.map(projectDiagnosticEventForSharing) const files = [ { name: 'README.txt', @@ -246,19 +343,23 @@ export class DiagnosticsService { }, { name: 'diagnostics.jsonl', - content: events.map((event) => JSON.stringify(this.sanitizeValue(event))).join('\n') + (events.length ? '\n' : ''), + content: sharedEvents.map((event) => JSON.stringify(event)).join('\n') + (sharedEvents.length ? '\n' : ''), }, { name: 'recent-errors.md', - content: this.buildRecentErrorsSummary(events), + content: this.buildRecentErrorsSummary(sharedEvents), }, { name: 'runtime-errors.log', - content: await this.readSanitizedTextFile(this.getRuntimeErrorsPath(), MAX_TEXT_FILE_EXPORT_LENGTH), + content: this.buildSharedRuntimeLog(sharedEvents), }, { name: 'cli-diagnostics.jsonl', - content: await this.readSanitizedTextFile(this.getCliDiagnosticsPath(), MAX_TEXT_FILE_EXPORT_LENGTH), + content: await this.readCliDiagnosticsForSharing(MAX_TEXT_FILE_EXPORT_LENGTH), + }, + { + name: 'electron-host.log', + content: await this.readSanitizedTextFile(this.getElectronHostPath(), MAX_TEXT_FILE_EXPORT_LENGTH), }, { name: 'providers-summary.json', @@ -266,16 +367,30 @@ export class DiagnosticsService { }, { name: 'sessions-summary.json', - content: JSON.stringify(this.buildSessionsSummary(events), null, 2) + '\n', + content: JSON.stringify(this.buildSessionsSummary(sharedEvents), null, 2) + '\n', }, ] const archive = this.createTarGz(files) await fs.mkdir(this.getExportDir(), { recursive: true }) await fs.writeFile(outPath, archive) + await this.enforceExportRetention(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000) return { path: outPath, fileName, bytes: archive.byteLength } } + async buildIssueReport(): Promise { + await this.ensureLogDir() + await this.runRetentionSerialized(true, true) + const scan = await this.scanDiagnosticsFile() + return buildDiagnosticsIssueReport({ + generatedAt: new Date().toISOString(), + appInfo: this.buildAppInfo(), + providersSummary: await this.buildProvidersSummary(), + events: scan.events.slice(-MAX_ISSUE_REPORT_EVENTS).reverse().map(projectDiagnosticEventForSharing), + corruptLineCount: scan.corruptLineCount, + }) + } + async openLogDir(): Promise { await this.ensureLogDir() const dir = this.getLogDir() @@ -336,6 +451,9 @@ export class DiagnosticsService { .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]') + .replace(/\bAKIA[A-Z0-9]{16}\b/g, '[REDACTED_AWS_ACCESS_KEY]') + .replace(/(?:\/Users|\/home|\/private|\/var\/folders|\/tmp)\/[^\s"'`]+/g, '[REDACTED_PATH]') + .replace(/\b[A-Z]:\\(?:[^\s"'`]+\\)*[^\s"'`]*/gi, '[REDACTED_PATH]') const home = os.homedir() if (home && sanitized.includes(home)) { @@ -406,22 +524,25 @@ export class DiagnosticsService { '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.', + 'Content-bearing fields are omitted and secrets/personal data receive best-effort redaction.', + 'Included metadata may contain event ids, timestamps, event types, severity, session ids, error codes/status, provider/model identifiers, and provider hostnames.', 'Paths under the current home directory are normalized to "~". Long fields are truncated.', + 'Review every file before sharing because automated redaction cannot guarantee removal of all sensitive data.', '', 'Files:', '- app-info.json: runtime and platform summary.', '- diagnostics.jsonl: sanitized structured diagnostic events.', '- recent-errors.md: human-readable warning/error timeline for GitHub issues.', - '- runtime-errors.log: sanitized warning/error timeline with captured runtime details.', + '- runtime-errors.log: warning/error timeline with projected diagnostic metadata/details.', '- cli-diagnostics.jsonl: sanitized no-PII CLI internal diagnostics emitted by the child process.', + '- electron-host.log: sanitized recent Electron host lifecycle diagnostics.', '- 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 buildRecentErrorsSummary(events: DiagnosticEvent[]): string { + private buildRecentErrorsSummary(events: SharedDiagnosticEvent[]): string { const errorEvents = events .filter((event) => event.severity === 'error' || event.severity === 'warn') .slice(0, 50) @@ -444,7 +565,6 @@ export class DiagnosticsService { lines.push(`## ${event.timestamp} ${event.severity.toUpperCase()} ${event.type}`) if (event.sessionId) lines.push(`session: ${event.sessionId}`) lines.push('') - lines.push(event.summary) if (event.details !== undefined) { lines.push('') lines.push('```json') @@ -457,6 +577,20 @@ export class DiagnosticsService { return lines.join('\n') } + private buildSharedRuntimeLog(events: SharedDiagnosticEvent[]): string { + const errorEvents = events.filter((event) => event.severity === 'error' || event.severity === 'warn') + return errorEvents + .map((event) => { + const lines = [ + `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}`, + ] + if (event.details !== undefined) lines.push(JSON.stringify(event.details, null, 2)) + if (event.omittedFields.length > 0) lines.push(`omittedFields: ${event.omittedFields.join(', ')}`) + return lines.join('\n') + }) + .join('\n\n') + (errorEvents.length > 0 ? '\n' : '') + } + private buildAppInfo(): Record { return this.sanitizeValue({ appVersion: process.env.APP_VERSION || '999.0.0-local', @@ -464,7 +598,6 @@ export class DiagnosticsService { 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 @@ -502,13 +635,13 @@ export class DiagnosticsService { if (!value.trim()) return null try { const url = new URL(value) - return { protocol: url.protocol, host: url.host, pathname: url.pathname } + return { hostname: url.hostname } } catch { - return { value: this.sanitizeString(value, 512) } + return null } } - private buildSessionsSummary(events: DiagnosticEvent[]): Record { + private buildSessionsSummary(events: SharedDiagnosticEvent[]): Record { const sessions = new Map }>() for (const event of events) { if (!event.sessionId) continue @@ -538,32 +671,345 @@ export class DiagnosticsService { filePath: string, maxLength = 2 * MAX_STRING_LENGTH, ): Promise { + let file: fs.FileHandle | undefined try { - return this.sanitizeString(await fs.readFile(filePath, 'utf-8'), maxLength) + file = await fs.open(filePath, 'r') + const stat = await file.stat() + if (stat.size <= maxLength) { + return this.sanitizeString(await file.readFile('utf-8'), maxLength) + } + + const readLength = Math.min(stat.size, maxLength + MAX_STRING_LENGTH) + const start = stat.size - readLength + const buffer = Buffer.alloc(readLength) + await file.read(buffer, 0, readLength, start) + let rawTail = buffer.toString('utf-8') + if (start > 0) { + const firstCompleteLine = rawTail.indexOf('\n') + rawTail = firstCompleteLine === -1 ? '' : rawTail.slice(firstCompleteLine + 1) + } + const sanitizedTail = this.sanitizeString(rawTail, Number.MAX_SAFE_INTEGER) + return TRUNCATED_OLDER_CONTENT_MARKER + sanitizedTail.slice(-maxLength) } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return '' throw err + } finally { + await file?.close() } } - private async enforceRetention(): Promise { - const dir = this.getLogDir() - const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000 - const files = await this.listFiles(dir) + private async readCliDiagnosticsForSharing(maxLength: number): Promise { + const baseName = path.basename(this.getCliDiagnosticsPath()) + const successfullyReadPaths = new Set() + const seenContentDigests = new Set() + const chunks: string[] = [] + // A writer may atomically rotate current -> completed between listing and + // opening. Re-list a fixed number of times so the moved inode is normally + // picked up under its new name without ever locking or mutating it. + for (let attempt = 0; attempt < 3; attempt += 1) { + const files = (await this.listFiles(this.getLogDir())) + .filter((file) => path.dirname(file.path) === this.getLogDir()) + .filter((file) => { + const name = path.basename(file.path) + return name === baseName || (name.startsWith(`${baseName}.`) && name.endsWith('.jsonl')) + }) + .sort((left, right) => left.mtimeMs - right.mtimeMs) + for (const file of files) { + if (successfullyReadPaths.has(file.path)) continue + const content = await this.readSanitizedTextFile(file.path, maxLength) + if (!content) continue + successfullyReadPaths.add(file.path) + const digest = this.hashBuffer(Buffer.from(content, 'utf-8')) + if (seenContentDigests.has(digest)) continue + seenContentDigests.add(digest) + chunks.push(content) + } + } + const combined = chunks.join('') + return combined.length > maxLength + ? TRUNCATED_OLDER_CONTENT_MARKER + combined.slice(-maxLength) + : combined + } - for (const file of files) { - if (file.mtimeMs < cutoff) { - await fs.rm(file.path, { force: true }) + private async runRetentionSerialized(force: boolean, compactStructured: boolean): Promise { + const operation = this.writeQueue.then(() => this.enforceRetention(force, compactStructured)) + this.writeQueue = operation.then(() => undefined, () => undefined) + await operation + } + + private async enforceRetention(force = false, compactStructured = true): Promise { + const diagnosticsPath = this.getDiagnosticsPath() + const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000 + const stat = await fs.stat(diagnosticsPath).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null + throw error + }) + const forceCompaction = (stat?.size ?? 0) > MAX_DIAGNOSTICS_BYTES + const now = Date.now() + if (!force && !forceCompaction && now - this.lastRetentionSweepAt < RETENTION_SWEEP_INTERVAL_MS) return + if (stat) { + const scan = await this.scanDiagnosticsFile() + const hasStaleEvent = scan.events.some((event) => Date.parse(event.timestamp) < cutoff) + if (compactStructured || forceCompaction || hasStaleEvent) { + if (scan.rawCorruptLineCount > 0) { + await this.writePendingCorruptLineCount({ + corruptLineCount: scan.corruptLineCount, + sourceBytes: scan.sourceBytes, + sourceDigest: scan.sourceDigest, + }) + } + const retained = scan.events + .filter((event) => Date.parse(event.timestamp) >= cutoff) + .sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp)) + const newestLinesThatFit = this.keepNewestCompleteLinesWithinBytes(retained, MAX_DIAGNOSTICS_BYTES) + await this.rewriteDiagnosticsAtomically(newestLinesThatFit) + if (scan.rawCorruptLineCount > 0) await this.commitPendingCorruptLineCount() } } - 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 + await Promise.all([ + this.truncateTextFileToTail(this.getRuntimeErrorsPath(), MAX_AUXILIARY_LOG_BYTES), + this.truncateTextFileToTail(this.getElectronHostPath(), MAX_AUXILIARY_LOG_BYTES), + this.enforceCliCompletedSegmentRetention(cutoff), + ]) + await this.enforceExportRetention(cutoff) + this.lastRetentionSweepAt = now + } + + private async readPersistedCorruptLineCount( + sourceBuffer = Buffer.alloc(0), + rawCorruptLineCount = 0, + ): Promise { + let committed = 0 + try { + const parsed = JSON.parse(await fs.readFile(this.getCorruptionEvidencePath(), 'utf-8')) as unknown + if (parsed && typeof parsed === 'object') { + const count = (parsed as Record).corruptLineCount + committed = typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : 0 + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error } + const pending = await this.readPendingCorruptEvidence() + if (!pending) return committed + const pendingSourceStillPresent = sourceBuffer.byteLength >= pending.sourceBytes && + this.hashBuffer(sourceBuffer.subarray(0, pending.sourceBytes)) === pending.sourceDigest + if (rawCorruptLineCount > 0 && pendingSourceStillPresent) return committed + return Math.max(committed, pending.corruptLineCount) + } + + private async readPendingCorruptEvidence(): Promise { + try { + const parsed = JSON.parse(await fs.readFile(this.getPendingCorruptionEvidencePath(), 'utf-8')) as unknown + if (!parsed || typeof parsed !== 'object') return null + const record = parsed as Record + return typeof record.corruptLineCount === 'number' && Number.isSafeInteger(record.corruptLineCount) && record.corruptLineCount >= 0 && + typeof record.sourceBytes === 'number' && Number.isSafeInteger(record.sourceBytes) && record.sourceBytes >= 0 && + typeof record.sourceDigest === 'string' + ? record as PendingCorruptionEvidence + : null + } catch { + return null + } + } + + private async writePendingCorruptLineCount(evidence: PendingCorruptionEvidence): Promise { + await fs.writeFile( + this.getPendingCorruptionEvidencePath(), + `${JSON.stringify(evidence)}\n`, + { encoding: 'utf-8', mode: 0o600 }, + ) + } + + private async commitPendingCorruptLineCount(): Promise { + await fs.rename(this.getPendingCorruptionEvidencePath(), this.getCorruptionEvidencePath()) + } + + private hashBuffer(buffer: Buffer): string { + return createHash('sha256').update(buffer).digest('hex') + } + + private async truncateTextFileToTail(filePath: string, maxBytes: number): Promise { + let file: fs.FileHandle | undefined + try { + file = await fs.open(filePath, 'r') + const stat = await file.stat() + if (stat.size <= maxBytes) return + const readLength = Math.min(stat.size, maxBytes + MAX_STRING_LENGTH) + const buffer = Buffer.alloc(readLength) + await file.read(buffer, 0, readLength, stat.size - readLength) + await file.close() + file = undefined + let tail = buffer.toString('utf-8') + const firstCompleteLine = tail.indexOf('\n') + tail = firstCompleteLine === -1 ? tail.slice(-maxBytes) : tail.slice(firstCompleteLine + 1) + while (Buffer.byteLength(tail, 'utf-8') > maxBytes) tail = tail.slice(1) + const temporaryPath = `${filePath}.${crypto.randomUUID()}.tmp` + try { + await fs.writeFile(temporaryPath, tail, { encoding: 'utf-8', mode: 0o600 }) + await fs.rename(temporaryPath, filePath) + } finally { + await fs.rm(temporaryPath, { force: true }) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } finally { + await file?.close() + } + } + + private async enforceExportRetention(cutoff: number): Promise { + const files = (await this.listFiles(this.getExportDir())).sort((left, right) => left.mtimeMs - right.mtimeMs) + let retainedBytes = files.reduce((sum, file) => sum + file.size, 0) + for (const file of files) { + if (file.mtimeMs >= cutoff && retainedBytes <= MAX_EXPORT_DIRECTORY_BYTES) continue + await fs.rm(file.path, { force: true }) + retainedBytes -= file.size + } + } + + private async enforceCliCompletedSegmentRetention(cutoff: number): Promise { + const baseName = path.basename(this.getCliDiagnosticsPath()) + const cliFiles = (await this.listFiles(this.getLogDir())) + .filter((file) => path.dirname(file.path) === this.getLogDir()) + .filter((file) => { + const name = path.basename(file.path) + return name === baseName || (name.startsWith(`${baseName}.`) && name.endsWith('.jsonl')) + }) + const removed = new Set() + for (const file of cliFiles) { + const name = path.basename(file.path) + if (name.includes('.reclaimed.')) { + if (file.mtimeMs < cutoff) { + await fs.rm(file.path, { force: true }) + removed.add(file.path) + } + continue + } + if (name === baseName) { + if (file.mtimeMs < cutoff) { + await this.quarantineCliAppendTarget(file.path) + removed.add(file.path) + } + continue + } + if (!name.endsWith('.current.jsonl')) continue + const pidMatch = name.match(new RegExp(`^${this.escapeRegExp(baseName)}\\.(\\d+)\\.current\\.jsonl$`)) + const liveness = pidMatch ? this.getPidLiveness(Number(pidMatch[1])) : 'unknown' + if (liveness === 'dead' || file.mtimeMs < cutoff) { + await this.quarantineCliAppendTarget(file.path) + removed.add(file.path) + } + } + const recentLegacyBytes = cliFiles + .filter((file) => path.basename(file.path) === baseName && !removed.has(file.path)) + .reduce((sum, file) => sum + file.size, 0) + const completed = cliFiles + .filter((file) => !removed.has(file.path)) + .filter((file) => { + const name = path.basename(file.path) + return name !== baseName && !name.endsWith('.current.jsonl') && !name.includes('.reclaimed.') + }) + .sort((left, right) => left.mtimeMs - right.mtimeMs) + let retainedBytes = recentLegacyBytes + completed.reduce((sum, file) => sum + file.size, 0) + for (const file of completed) { + if (file.mtimeMs >= cutoff && retainedBytes <= MAX_CLI_COMPLETED_SEGMENTS_BYTES) continue + await fs.rm(file.path, { force: true }) + retainedBytes -= file.size + } + } + + private async quarantineCliAppendTarget(filePath: string): Promise { + const currentSuffix = '.current.jsonl' + const stem = filePath.endsWith(currentSuffix) + ? filePath.slice(0, -currentSuffix.length) + : filePath + const quarantinePath = `${stem}.reclaimed.${crypto.randomUUID()}.jsonl` + try { + await fs.rename(filePath, quarantinePath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + + private getPidLiveness(pid: number): 'alive' | 'dead' | 'unknown' { + if (!Number.isSafeInteger(pid) || pid <= 0) return 'unknown' + try { + process.kill(pid, 0) + return 'alive' + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ESRCH') return 'dead' + if (code === 'EPERM') return 'alive' + return 'unknown' + } + } + + private escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + } + + private async isManagedSurfaceOverLimit(): Promise { + const files = await this.listFiles(this.getLogDir()) + const sizeOf = (filePath: string) => files.find((file) => file.path === filePath)?.size ?? 0 + const cliBaseName = path.basename(this.getCliDiagnosticsPath()) + const cliBytes = files + .filter((file) => path.dirname(file.path) === this.getLogDir()) + .filter((file) => { + const name = path.basename(file.path) + return name === cliBaseName || (name.startsWith(`${cliBaseName}.`) && name.endsWith('.jsonl')) + }) + .reduce((sum, file) => sum + file.size, 0) + const exportBytes = files + .filter((file) => file.path.startsWith(`${this.getExportDir()}${path.sep}`)) + .reduce((sum, file) => sum + file.size, 0) + return sizeOf(this.getDiagnosticsPath()) > MAX_DIAGNOSTICS_BYTES || + sizeOf(this.getRuntimeErrorsPath()) > MAX_AUXILIARY_LOG_BYTES || + sizeOf(this.getElectronHostPath()) > MAX_AUXILIARY_LOG_BYTES || + cliBytes > MAX_CLI_COMPLETED_SEGMENTS_BYTES || + exportBytes > MAX_EXPORT_DIRECTORY_BYTES + } + + private keepNewestCompleteLinesWithinBytes(events: DiagnosticEvent[], maxBytes: number): string[] { + const kept: string[] = [] + let totalBytes = 0 + for (let index = events.length - 1; index >= 0; index -= 1) { + const line = JSON.stringify(events[index]) + const lineBytes = Buffer.byteLength(line, 'utf-8') + 1 + if (lineBytes > maxBytes - totalBytes) break + kept.push(line) + totalBytes += lineBytes + } + return kept.reverse() + } + + private async rewriteDiagnosticsAtomically(lines: string[]): Promise { + const diagnosticsPath = this.getDiagnosticsPath() + const temporaryPath = `${diagnosticsPath}.${crypto.randomUUID()}.tmp` + const content = lines.length > 0 ? `${lines.join('\n')}\n` : '' + try { + await fs.writeFile(temporaryPath, content, 'utf-8') + await fs.rename(temporaryPath, diagnosticsPath) + } finally { + await fs.rm(temporaryPath, { force: true }) + } + } + + private sanitizeWriteError(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error) + return this.sanitizeString(raw).split(this.getConfigDir()).join('$CLAUDE_CONFIG_DIR') + } + + private isDiagnosticEvent(value: unknown): value is DiagnosticEvent { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false + const event = value as Record + return typeof event.id === 'string' && + typeof event.timestamp === 'string' && + Number.isFinite(Date.parse(event.timestamp)) && + typeof event.type === 'string' && + (event.severity === 'debug' || event.severity === 'info' || event.severity === 'warn' || event.severity === 'error') && + typeof event.summary === 'string' && + (event.sessionId === undefined || typeof event.sessionId === 'string') } private async getDirectorySize(dir: string): Promise { diff --git a/src/server/services/diagnosticsShare.test.ts b/src/server/services/diagnosticsShare.test.ts new file mode 100644 index 00000000..300beb14 --- /dev/null +++ b/src/server/services/diagnosticsShare.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from 'bun:test' +import type { DiagnosticEvent } from './diagnosticsService.js' +import { + buildDiagnosticsIssueReport, + projectDiagnosticEventForSharing, +} from './diagnosticsShare.js' + +describe('projectDiagnosticEventForSharing', () => { + test('reduces native and serialized errors to metadata without messages, stacks, paths, prompts, or cloud keys', () => { + const nativeError = new Error('PRIVATE_PROMPT at /Users/alice/private/project AKIAIOSFODNN7EXAMPLE') + nativeError.name = 'PRIVATE_PROMPT_CONTENT' + nativeError.stack = 'ProviderRequestError: PRIVATE_STACK\n at /Users/alice/private/project/index.ts:42:1' + const event: DiagnosticEvent = { + id: 'event-AKIAIOSFODNN7EXAMPLE', + timestamp: '2026-07-11T09:10:11.000Z', + type: 'provider_error', + severity: 'error', + summary: 'PRIVATE_SUMMARY', + sessionId: '/Users/alice/private/session', + details: { + nativeError, + serializedError: { + name: 'PRIVATE_SERIALIZED_ERROR_NAME', + message: 'PRIVATE_SERIALIZED_MESSAGE wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + stack: 'PRIVATE_SERIALIZED_STACK /home/alice/private.txt', + }, + }, + } + + const projected = projectDiagnosticEventForSharing(event) + const serialized = JSON.stringify(projected) + + expect(projected.details).toEqual({ + nativeError: { name: 'UnknownError' }, + serializedError: { name: 'UnknownError' }, + }) + for (const privateValue of [ + 'PRIVATE_PROMPT', + 'PRIVATE_STACK', + 'PRIVATE_SERIALIZED_MESSAGE', + 'PRIVATE_SERIALIZED_STACK', + 'PRIVATE_PROMPT_CONTENT', + 'PRIVATE_SERIALIZED_ERROR_NAME', + '/Users/alice', + '/home/alice', + 'AKIAIOSFODNN7EXAMPLE', + 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + ]) { + expect(serialized).not.toContain(privateValue) + } + expect(projected.omittedFields).toContain('details.nativeError.message') + expect(projected.omittedFields).toContain('details.nativeError.stack') + expect(projected.omittedFields).toContain('details.serializedError.message') + expect(projected.omittedFields).toContain('details.serializedError.stack') + }) + + test('keeps diagnostic metadata while omitting content and personal data', () => { + const error = new Error('request failed for sk-proj-PROJECTSECRET at user@example.com') + const event: DiagnosticEvent = { + id: 'event-share-safe-1', + timestamp: '2026-07-11T09:10:11.000Z', + type: 'sdk_result_error', + severity: 'error', + summary: 'PRIVATE_ASSISTANT_REPLY', + sessionId: 'session-safe-id', + details: { + errorCode: 'CLI_START_FAILED', + status: 'failed', + content: 'PRIVATE_CONTENT', + prompt: 'PRIVATE_PROMPT', + response: 'PRIVATE_RESPONSE', + capturedOutput: 'PRIVATE_CAPTURED_OUTPUT', + sdkMessages: [{ message: { content: [{ type: 'text', text: 'PRIVATE_ASSISTANT_REPLY' }] } }], + toolInput: 'PRIVATE_TOOL_INPUT', + toolOutput: 'PRIVATE_TOOL_OUTPUT', + assistantText: 'PRIVATE_ASSISTANT_TEXT', + bareAnthropicToken: 'sk-ant-api03-BARESECRET', + projectToken: 'sk-proj-PROJECTSECRET', + githubToken: 'ghp_GITHUBSECRET', + endpoint: 'https://private-user:private-pass@example.com/private/path?token=query-secret', + email: 'user@example.com', + error, + }, + } + + const projected = projectDiagnosticEventForSharing(event) + const serialized = JSON.stringify(projected) + + for (const privateValue of [ + 'PRIVATE_CONTENT', + 'PRIVATE_PROMPT', + 'PRIVATE_RESPONSE', + 'PRIVATE_CAPTURED_OUTPUT', + 'PRIVATE_ASSISTANT_REPLY', + 'PRIVATE_TOOL_INPUT', + 'PRIVATE_TOOL_OUTPUT', + 'PRIVATE_ASSISTANT_TEXT', + 'sk-ant-api03-BARESECRET', + 'sk-proj-PROJECTSECRET', + 'ghp_GITHUBSECRET', + 'private-user', + 'private-pass', + '/private/path', + 'query-secret', + 'user@example.com', + ]) { + expect(serialized).not.toContain(privateValue) + } + expect(projected.id).toBe(event.id) + expect(projected.type).toBe(event.type) + expect(projected.severity).toBe(event.severity) + expect(projected.details).toMatchObject({ errorCode: 'CLI_START_FAILED', status: 'failed' }) + expect(projected.omittedFields).toContain('summary') + expect(projected.omittedFields).toContain('details.sdkMessages') + expect(projected.omittedFields).toContain('details.error.message') + expect(projected.omittedFields).toContain('details.error.stack') + }) +}) + +describe('buildDiagnosticsIssueReport', () => { + test('builds a deterministic share-safe GitHub issue template', () => { + const report = buildDiagnosticsIssueReport({ + generatedAt: '2026-07-11T09:10:11.000Z', + appInfo: { + appVersion: '0.4.7', + platform: 'darwin', + arch: 'arm64', + bun: '1.2.18', + node: 'v22.17.0', + }, + providersSummary: { + activeId: 'provider-1', + count: 1, + providers: [{ + id: 'provider-1', + name: 'Test Provider', + apiFormat: 'anthropic', + baseUrl: { hostname: 'api.example.com' }, + models: { main: 'main-model' }, + }], + }, + events: [{ + id: 'event-report-1', + timestamp: '2026-07-11T09:00:00.000Z', + type: 'sdk_api_error', + severity: 'error', + details: { + errorCode: 'API_ERROR', + status: 'failed', + content: 'PRIVATE_ASSISTANT_REPLY', + }, + omittedFields: ['summary', 'details.sdkMessages'], + }], + corruptLineCount: 2, + }) + + expect(report).toContain('## 问题描述') + expect(report).toContain('期望行为') + expect(report).toContain('出现频率') + expect(report).toContain('## 运行环境') + expect(report).toContain('- App: 0.4.7') + expect(report).toContain('- OS/Arch: darwin / arm64') + expect(report).toContain('- Bun/Node: 1.2.18 / v22.17.0') + expect(report).toContain('- 安装来源: ') + expect(report).toContain('## Provider / 模型') + expect(report).toContain('api.example.com') + expect(report).toContain('main-model') + expect(report).toContain('## 诊断关联') + expect(report).toContain('- Event IDs: event-report-1') + expect(report).toContain('- Corrupt diagnostic lines: 2') + expect(report).toContain('检测到 2 行损坏的诊断记录') + expect(report).toContain('## 复现步骤') + expect(report).toContain('## 错误摘要') + expect(report).toContain('2026-07-11T09:00:00.000Z') + expect(report).not.toContain('PRIVATE_ASSISTANT_REPLY') + }) +}) diff --git a/src/server/services/diagnosticsShare.ts b/src/server/services/diagnosticsShare.ts new file mode 100644 index 00000000..af13051c --- /dev/null +++ b/src/server/services/diagnosticsShare.ts @@ -0,0 +1,263 @@ +import type { DiagnosticEvent, DiagnosticSeverity } from './diagnosticsService.js' + +export type SharedDiagnosticEvent = { + id: string + timestamp: string + type: string + severity: DiagnosticSeverity + sessionId?: string + details?: Record + omittedFields: string[] +} + +export type DiagnosticsIssueReportInput = { + generatedAt: string + appInfo: Record + providersSummary: Record + events: SharedDiagnosticEvent[] + corruptLineCount: number +} + +const CONTENT_BEARING_KEYS = new Set([ + 'assistanttext', + 'body', + 'capturedoutput', + 'content', + 'filecontent', + 'filecontents', + 'input', + 'message', + 'messagetext', + 'output', + 'prompt', + 'response', + 'result', + 'sdkmessages', + 'text', + 'toolinput', + 'tooloutput', + 'transcript', +]) + +const SAFE_SCALAR_KEYS = new Set([ + 'code', + 'errorcategory', + 'errorcode', + 'is_error', + 'isapierrormessage', + 'iserror', + 'name', + 'sdkType'.toLowerCase(), + 'status', + 'subtype', +]) + +const SAFE_METADATA_VALUE_RE = /^[a-z0-9][a-z0-9_.:/ -]{0,127}$/i +const URL_RE = /https?:\/\/[^\s<>"')\]}]+/gi +const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi +const SECRET_RE = /\b(?:sk-ant-api03-|sk-proj-|ghp_)[A-Za-z0-9_-]+\b/g +const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/-]+/gi +const AWS_ACCESS_KEY_RE = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g +const PRIVATE_PATH_RE = /(?:\/Users|\/home|\/private|\/var\/folders|\/tmp)\/[^\s<>"')\]}]+/g +const WINDOWS_PATH_RE = /\b[A-Z]:\\(?:[^\s<>"')\]}]+\\)*[^\s<>"')\]}]*/gi +const SAFE_ERROR_NAMES = new Set([ + 'AggregateError', + 'Error', + 'EvalError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'TypeError', + 'URIError', +]) + +export function projectDiagnosticEventForSharing(event: DiagnosticEvent): SharedDiagnosticEvent { + const omittedFields = ['summary'] + const details = projectDetails(event.details, 'details', omittedFields) + return { + id: sanitizeSharedString(event.id), + timestamp: sanitizeSharedString(event.timestamp), + type: sanitizeSharedString(event.type), + severity: event.severity, + ...(event.sessionId ? { sessionId: sanitizeSharedString(event.sessionId) } : {}), + ...(details ? { details } : {}), + omittedFields: [...new Set(omittedFields)].sort(), + } +} + +export function buildDiagnosticsIssueReport(input: DiagnosticsIssueReportInput): string { + const eventIds = input.events.map((event) => event.id).join(', ') || 'None' + const providers = Array.isArray(input.providersSummary.providers) + ? input.providersSummary.providers as Array> + : [] + const providerLines = providers.length > 0 + ? providers.map(formatProviderLine) + : ['- 未配置 Provider'] + const recentErrors = input.events + .filter((event) => event.severity === 'error' || event.severity === 'warn') + .slice(0, 50) + const errorLines = recentErrors.length > 0 + ? recentErrors.map(formatErrorLine) + : ['- 未记录最近的警告或错误。'] + const corruptionWarning = input.corruptLineCount > 0 + ? `> 警告:检测到 ${input.corruptLineCount} 行损坏的诊断记录,以下信息可能不完整。` + : '> 未检测到损坏的诊断记录。' + + return [ + ``, + '', + '## 问题描述', + '', + '', + '- 期望行为: ', + '- 出现频率: ', + '', + '## 运行环境', + `- App: ${formatMetadata(input.appInfo.appVersion)}`, + `- OS/Arch: ${formatMetadata(input.appInfo.platform)} / ${formatMetadata(input.appInfo.arch)}`, + `- Bun/Node: ${formatMetadata(input.appInfo.bun)} / ${formatMetadata(input.appInfo.node)}`, + '- 安装来源: ', + '', + '## Provider / 模型', + ...providerLines, + '', + '## 诊断关联', + `- Event IDs: ${eventIds}`, + `- Corrupt diagnostic lines: ${input.corruptLineCount}`, + '', + corruptionWarning, + '', + '## 复现步骤', + '1. ', + '', + '## 错误摘要', + ...errorLines, + '', + ].join('\n') +} + +function projectDetails( + value: unknown, + path: string, + omittedFields: string[], +): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + if (value !== undefined) omittedFields.push(path) + return undefined + } + if (value instanceof Error) { + omittedFields.push(`${path}.message`, `${path}.stack`) + return { error: projectError(value) } + } + + const projected: Record = {} + for (const [key, entry] of Object.entries(value as Record)) { + const entryPath = `${path}.${key}` + const normalizedKey = key.toLowerCase() + if (CONTENT_BEARING_KEYS.has(normalizedKey)) { + omittedFields.push(entryPath) + continue + } + if (entry instanceof Error) { + omittedFields.push(`${entryPath}.message`, `${entryPath}.stack`) + projected[key] = projectError(entry) + continue + } + if (isSerializedError(entry)) { + omittedFields.push(`${entryPath}.message`, `${entryPath}.stack`) + projected[key] = projectSerializedError(entry) + continue + } + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + const nested = projectDetails(entry, entryPath, omittedFields) + if (nested && Object.keys(nested).length > 0) projected[key] = nested + continue + } + if (SAFE_SCALAR_KEYS.has(normalizedKey) && isScalar(entry)) { + projected[key] = projectSafeMetadataScalar(entry) + continue + } + omittedFields.push(entryPath) + } + return Object.keys(projected).length > 0 ? projected : undefined +} + +function projectError(error: Error): Record { + return { + name: projectErrorName(error.name), + } +} + +function isSerializedError(value: unknown): value is { name: string; message: string; stack?: string } { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const record = value as Record + const keys = Object.keys(record) + return typeof record.name === 'string' && typeof record.message === 'string' && + keys.every((key) => key === 'name' || key === 'message' || key === 'stack') +} + +function projectSerializedError(error: { name: string; message: string; stack?: string }): Record { + return { + name: projectErrorName(error.name), + } +} + +function projectErrorName(name: string): string { + return SAFE_ERROR_NAMES.has(name) ? name : 'UnknownError' +} + +function isScalar(value: unknown): value is string | number | boolean | null { + return value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' +} + +function projectSafeMetadataScalar(value: string | number | boolean | null): string | number | boolean | null { + if (typeof value !== 'string') return value + const sanitized = sanitizeSharedString(value) + return SAFE_METADATA_VALUE_RE.test(sanitized) ? sanitized : '[REDACTED]' +} + +function sanitizeSharedString(value: string): string { + return value + .replace(URL_RE, (candidate) => { + try { + return new URL(candidate).hostname + } catch { + return '[REDACTED_URL]' + } + }) + .replace(SECRET_RE, '[REDACTED]') + .replace(BEARER_RE, 'Bearer [REDACTED]') + .replace(EMAIL_RE, '[REDACTED_EMAIL]') + .replace(AWS_ACCESS_KEY_RE, '[REDACTED_AWS_ACCESS_KEY]') + .replace(PRIVATE_PATH_RE, '[REDACTED_PATH]') + .replace(WINDOWS_PATH_RE, '[REDACTED_PATH]') +} + +function formatMetadata(value: unknown): string { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return sanitizeSharedString(String(value)) + } + return 'unknown' +} + +function formatProviderLine(provider: Record): string { + const baseUrl = provider.baseUrl && typeof provider.baseUrl === 'object' + ? provider.baseUrl as Record + : {} + const models = provider.models && typeof provider.models === 'object' && !Array.isArray(provider.models) + ? Object.entries(provider.models as Record) + .filter(([, value]) => typeof value === 'string') + .map(([key, value]) => `${sanitizeSharedString(key)}=${sanitizeSharedString(String(value))}`) + .join(', ') + : 'None' + return `- ${formatMetadata(provider.name ?? provider.id)} | ${formatMetadata(provider.apiFormat)} | Host: ${formatMetadata(baseUrl.hostname)} | Models: ${models || 'None'}` +} + +function formatErrorLine(event: SharedDiagnosticEvent): string { + const details = event.details ?? {} + const metadata = [ + typeof details.errorCode === 'string' ? `errorCode=${details.errorCode}` : '', + typeof details.status === 'string' ? `status=${details.status}` : '', + ].filter(Boolean).join(', ') + return `- ${event.timestamp} [${event.severity.toUpperCase()}] ${event.type} (${event.id})${metadata ? ` — ${metadata}` : ''}` +} diff --git a/src/server/services/doctorService.ts b/src/server/services/doctorService.ts index e6964f13..5c373fa9 100644 --- a/src/server/services/doctorService.ts +++ b/src/server/services/doctorService.ts @@ -3,10 +3,11 @@ import * as os from 'node:os' import * as path from 'node:path' import type { Dirent } from 'node:fs' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' +import { ProvidersIndexSchema } from '../types/provider.js' import { diagnosticsService } from './diagnosticsService.js' export type DoctorItemKind = 'json' | 'jsonl' | 'directory' -export type DoctorItemStatus = 'ok' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'unreadable' +export type DoctorItemStatus = 'ok' | 'not_configured' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'invalid_schema' | 'unreadable' export type DoctorSkipReason = 'protected' export type DoctorReportItem = { @@ -38,6 +39,7 @@ export type DoctorReport = { summary: { total: number protectedCount: number + neutralCount: number missingCount: number invalidCount: number } @@ -79,6 +81,7 @@ type DoctorTarget = { scope: 'user' | 'project' filePath: string protected: true + required: boolean } export class DoctorService { @@ -112,10 +115,12 @@ export class DoctorService { summary: { total: items.length, protectedCount: protectedSkips.length, + neutralCount: items.filter((item) => item.status === 'not_configured').length, missingCount: items.filter((item) => item.status === 'missing').length, invalidCount: items.filter((item) => item.status === 'invalid_json' || item.status === 'invalid_jsonl' || + item.status === 'invalid_schema' || item.status === 'unreadable' ).length, }, @@ -273,6 +278,21 @@ export class DoctorService { try { const parsed = JSON.parse(raw) + if (target.id === 'cc-haha-providers') { + const result = ProvidersIndexSchema.safeParse(parsed) + if (!result.success) { + const error = result.error.issues + .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) + .join('; ') + return { + ...this.baseItem(target), + exists: true, + status: 'invalid_schema', + bytes, + error: this.sanitizeText(error), + } + } + } return { ...this.baseItem(target), exists: true, @@ -345,7 +365,7 @@ export class DoctorService { return { ...this.baseItem(target), exists: false, - status: 'missing', + status: target.required ? 'missing' : 'not_configured', bytes: 0, } } @@ -453,7 +473,7 @@ export class DoctorService { scope: 'user' | 'project', filePath: string, ): DoctorTarget { - return { id, label, kind: 'json', scope, filePath, protected: true } + return { id, label, kind: 'json', scope, filePath, protected: true, required: false } } private jsonlTarget( @@ -462,7 +482,7 @@ export class DoctorService { scope: 'user' | 'project', filePath: string, ): DoctorTarget { - return { id, label, kind: 'jsonl', scope, filePath, protected: true } + return { id, label, kind: 'jsonl', scope, filePath, protected: true, required: false } } private directoryTarget( @@ -471,7 +491,7 @@ export class DoctorService { scope: 'user' | 'project', filePath: string, ): DoctorTarget { - return { id, label, kind: 'directory', scope, filePath, protected: true } + return { id, label, kind: 'directory', scope, filePath, protected: true, required: false } } private withAlias(alias: string, filePath: string, root: string): string { diff --git a/src/utils/diagLogs.test.ts b/src/utils/diagLogs.test.ts new file mode 100644 index 00000000..1a23624d --- /dev/null +++ b/src/utils/diagLogs.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'node:fs' +import * as fsp from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { logForDiagnosticsNoPII } from './diagLogs.js' + +let tmpDir: string +let originalPath: string | undefined + +beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cc-haha-diag-writer-')) + originalPath = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE + process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = path.join(tmpDir, 'cli-diagnostics.jsonl') +}) + +afterEach(async () => { + if (originalPath === undefined) delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE + else process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = originalPath + await fsp.rm(tmpDir, { recursive: true, force: true }) +}) + +describe('logForDiagnosticsNoPII', () => { + test('owns a per-process segment and rotates it without replacing a shared append target', async () => { + const basePath = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE! + const activePath = `${basePath}.${process.pid}.current.jsonl` + fs.writeFileSync(activePath, 'x'.repeat(1024 * 1024)) + + logForDiagnosticsNoPII('error', 'after_rotation', { code: 'ROTATED' }) + + expect(fs.existsSync(basePath)).toBe(false) + expect(fs.readFileSync(activePath, 'utf-8')).toContain('after_rotation') + const completedSegments = (await fsp.readdir(tmpDir)).filter((name) => + name.startsWith(`cli-diagnostics.jsonl.${process.pid}.`) && !name.includes('.current.'), + ) + expect(completedSegments).toHaveLength(1) + }) +}) diff --git a/src/utils/diagLogs.ts b/src/utils/diagLogs.ts index a2a3d382..877dbb4d 100644 --- a/src/utils/diagLogs.ts +++ b/src/utils/diagLogs.ts @@ -1,4 +1,4 @@ -import { dirname } from 'path' +import { basename, dirname } from 'path' import { getFsImplementation } from './fsOperations.js' import { jsonStringify } from './slowOperations.js' @@ -11,6 +11,10 @@ type DiagnosticLogEntry = { data: Record } +const MAX_SEGMENT_BYTES = 1024 * 1024 +const MAX_COMPLETED_SEGMENTS = 4 +let segmentSequence = 0 + /** * Logs diagnostic information to a logfile. This information is sent * via the environment manager to session-ingress to monitor issues from @@ -29,8 +33,8 @@ export function logForDiagnosticsNoPII( event: string, data?: Record, ): void { - const logFile = getDiagnosticLogFile() - if (!logFile) { + const baseLogFile = getDiagnosticLogFile() + if (!baseLogFile) { return } @@ -43,12 +47,15 @@ export function logForDiagnosticsNoPII( const fs = getFsImplementation() const line = jsonStringify(entry) + '\n' + const logFile = `${baseLogFile}.${process.pid}.current.jsonl` try { + rotateOwnedSegmentIfNeeded(fs, baseLogFile, logFile, Buffer.byteLength(line)) fs.appendFileSync(logFile, line) } catch { // If append fails, try creating the directory first try { fs.mkdirSync(dirname(logFile)) + rotateOwnedSegmentIfNeeded(fs, baseLogFile, logFile, Buffer.byteLength(line)) fs.appendFileSync(logFile, line) } catch { // Silently fail if logging is not possible @@ -56,6 +63,26 @@ export function logForDiagnosticsNoPII( } } +function rotateOwnedSegmentIfNeeded( + fs: ReturnType, + baseLogFile: string, + activeLogFile: string, + incomingBytes: number, +): void { + if (!fs.existsSync(activeLogFile)) return + if (fs.statSync(activeLogFile).size + incomingBytes <= MAX_SEGMENT_BYTES) return + segmentSequence += 1 + const completedPath = `${baseLogFile}.${process.pid}.${Date.now()}-${segmentSequence}.jsonl` + fs.renameSync(activeLogFile, completedPath) + const prefix = `${basename(baseLogFile)}.${process.pid}.` + const completed = fs.readdirStringSync(dirname(baseLogFile)) + .filter((name) => name.startsWith(prefix) && name.endsWith('.jsonl') && !name.endsWith('.current.jsonl')) + .sort() + for (const staleName of completed.slice(0, -MAX_COMPLETED_SEGMENTS)) { + fs.unlinkSync(`${dirname(baseLogFile)}/${staleName}`) + } +} + function getDiagnosticLogFile(): string | undefined { return process.env.CLAUDE_CODE_DIAGNOSTICS_FILE }