cc-haha/src/server/services/doctorService.ts
程序员阿江(Relakkes) 7ae885a114 fix: add safe Doctor rescue path for desktop upgrades
Online upgrades can strand users on stale desktop UI state or malformed local persistence. This adds a deny-by-default Doctor path that resets only regenerable desktop UI state, reports protected local files with redacted metadata, and keeps protected repair as a dry-run no-op until a reviewed backup-first flow exists.

Constraint: Chat transcripts, model/provider config, Skills, MCP, IM bindings, adapter sessions, OAuth tokens, plugins, and team/session records are user-owned protected state.
Rejected: Automatically rewrite malformed protected JSON | unsafe without schema-specific migrations and backups.
Rejected: Continue relying only on startup migrations | users need an explicit recovery action after a white screen.
Confidence: high
Scope-risk: moderate
Directive: Keep Doctor repair deny-by-default; do not mutate protected state without an explicit reviewed backup-first manual repair flow.
Tested: bun test src/server/__tests__/doctor-service.test.ts
Tested: cd desktop && bun run test src/components/ErrorBoundary.test.tsx src/lib/doctorRepair.test.ts src/__tests__/diagnosticsSettings.test.tsx src/components/layout/StartupErrorView.test.tsx
Tested: cd desktop && bun run lint
Tested: bun run check:server
Tested: bun run verify (failed only existing agent-utils coverage baseline; changed-lines coverage 97.62%)
Not-tested: Packaged desktop manual Doctor click path.
2026-05-07 00:04:06 +08:00

510 lines
14 KiB
TypeScript

import * as fs from 'node:fs/promises'
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 { diagnosticsService } from './diagnosticsService.js'
export type DoctorItemKind = 'json' | 'jsonl' | 'directory'
export type DoctorItemStatus = 'ok' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'unreadable'
export type DoctorSkipReason = 'protected'
export type DoctorReportItem = {
id: string
label: string
kind: DoctorItemKind
scope: 'user' | 'project'
path: string
protected: boolean
exists: boolean
status: DoctorItemStatus
bytes: number
entryCount?: number
lineCount?: number
invalidLineCount?: number
error?: string
}
export type DoctorProtectedSkip = {
id: string
path: string
reason: DoctorSkipReason
}
export type DoctorReport = {
generatedAt: string
items: DoctorReportItem[]
protectedSkips: DoctorProtectedSkip[]
summary: {
total: number
protectedCount: number
missingCount: number
invalidCount: number
}
}
export type DoctorRepairOperation = {
id: string
path: string
action: 'would_repair'
}
export type DoctorRepairSkip = {
id: string
path: string
reason: DoctorSkipReason
}
export type DoctorRepairResult = {
dryRun: true
mutated: false
operations: DoctorRepairOperation[]
skips: DoctorRepairSkip[]
summary: {
operationCount: number
skipCount: number
}
}
type DoctorServiceOptions = {
configDir?: string
homeDir?: string
projectRoot?: string
}
type DoctorTarget = {
id: string
label: string
kind: DoctorItemKind
scope: 'user' | 'project'
filePath: string
protected: true
}
export class DoctorService {
private readonly configDir: string
private readonly homeDir: string
private readonly projectRoot?: string
private readonly usesConfigDirOverride: boolean
constructor(options: DoctorServiceOptions = {}) {
this.configDir = options.configDir || getClaudeConfigHomeDir()
this.homeDir = options.homeDir || inferHomeDir(this.configDir)
this.projectRoot = options.projectRoot
this.usesConfigDirOverride = Boolean(options.configDir || process.env.CLAUDE_CONFIG_DIR)
}
async getReport(): Promise<DoctorReport> {
const targets = await this.buildTargets()
const items = await Promise.all(targets.map((target) => this.inspectTarget(target)))
const protectedSkips = items
.filter((item) => item.protected)
.map((item) => ({
id: item.id,
path: item.path,
reason: 'protected' as const,
}))
return {
generatedAt: new Date().toISOString(),
items,
protectedSkips,
summary: {
total: items.length,
protectedCount: protectedSkips.length,
missingCount: items.filter((item) => item.status === 'missing').length,
invalidCount: items.filter((item) =>
item.status === 'invalid_json' ||
item.status === 'invalid_jsonl' ||
item.status === 'unreadable'
).length,
},
}
}
async repair(targetIds?: string[]): Promise<DoctorRepairResult> {
const report = await this.getReport()
const selectedIds = targetIds?.length ? new Set(targetIds) : null
const selectedItems = selectedIds
? report.items.filter((item) => selectedIds.has(item.id))
: report.items
const operations: DoctorRepairOperation[] = []
const skips = selectedItems.map((item) => {
if (!item.protected && item.status !== 'ok') {
operations.push({
id: item.id,
path: item.path,
action: 'would_repair',
})
}
return {
id: item.id,
path: item.path,
reason: 'protected' as const,
}
})
return {
dryRun: true,
mutated: false,
operations,
skips,
summary: {
operationCount: operations.length,
skipCount: skips.length,
},
}
}
private async buildTargets(): Promise<DoctorTarget[]> {
const targets: DoctorTarget[] = [
this.jsonTarget('user-settings', 'User settings', 'user', path.join(this.configDir, 'settings.json')),
this.jsonTarget(
'cc-haha-providers',
'Managed providers',
'user',
path.join(this.configDir, 'cc-haha', 'providers.json'),
),
this.jsonTarget(
'cc-haha-settings',
'Managed provider settings',
'user',
path.join(this.configDir, 'cc-haha', 'settings.json'),
),
this.jsonTarget('adapters', 'Adapters config', 'user', path.join(this.configDir, 'adapters.json')),
this.jsonTarget(
'adapter-sessions',
'Adapter sessions',
'user',
path.join(this.configDir, 'adapter-sessions.json'),
),
this.directoryTarget('user-skills', 'User skills', 'user', path.join(this.configDir, 'skills')),
this.directoryTarget('teams', 'Teams', 'user', path.join(this.configDir, 'teams')),
this.directoryTarget('plugins', 'Plugins', 'user', path.join(this.configDir, 'plugins')),
this.directoryTarget(
'cowork-plugins',
'Cowork plugins',
'user',
path.join(this.configDir, 'cowork_plugins'),
),
this.jsonTarget('user-mcp', 'User MCP config', 'user', this.getUserMcpConfigPath()),
this.jsonTarget('oauth', 'OAuth tokens', 'user', path.join(this.configDir, 'cc-haha', 'oauth.json')),
this.jsonTarget(
'openai-oauth',
'OpenAI OAuth tokens',
'user',
path.join(this.configDir, 'cc-haha', 'openai-oauth.json'),
),
]
if (this.projectRoot) {
targets.push(
this.jsonTarget(
'project-settings',
'Project settings',
'project',
path.join(this.projectRoot, '.claude', 'settings.json'),
),
this.directoryTarget(
'project-skills',
'Project skills',
'project',
path.join(this.projectRoot, '.claude', 'skills'),
),
this.jsonTarget(
'project-mcp',
'Project MCP config',
'project',
path.join(this.projectRoot, '.mcp.json'),
),
)
}
const sessionFiles = await this.listJsonlFiles(path.join(this.configDir, 'projects'))
for (const filePath of sessionFiles) {
const relativePath = toPosix(path.relative(this.configDir, filePath))
targets.push(
this.jsonlTarget(
`session-jsonl:${relativePath}`,
'Session transcript',
'user',
filePath,
),
)
}
return targets
}
private async inspectTarget(target: DoctorTarget): Promise<DoctorReportItem> {
switch (target.kind) {
case 'json':
return this.inspectJsonTarget(target)
case 'jsonl':
return this.inspectJsonlTarget(target)
case 'directory':
return this.inspectDirectoryTarget(target)
default:
return this.inspectMissingTarget(target)
}
}
private async inspectJsonTarget(target: DoctorTarget): Promise<DoctorReportItem> {
const exists = await this.pathExists(target.filePath)
if (!exists) return this.inspectMissingTarget(target)
let raw: string
try {
raw = await fs.readFile(target.filePath, 'utf-8')
} catch (error) {
return this.inspectUnreadableTarget(target, error)
}
const bytes = Buffer.byteLength(raw, 'utf-8')
if (!raw.trim()) {
return {
...this.baseItem(target),
exists: true,
status: 'invalid_json',
bytes,
error: 'Empty JSON file',
}
}
try {
const parsed = JSON.parse(raw)
return {
...this.baseItem(target),
exists: true,
status: 'ok',
bytes,
entryCount: countJsonEntries(parsed),
}
} catch (error) {
return {
...this.baseItem(target),
exists: true,
status: 'invalid_json',
bytes,
error: this.sanitizeText(error instanceof Error ? error.message : String(error)),
}
}
}
private async inspectJsonlTarget(target: DoctorTarget): Promise<DoctorReportItem> {
const exists = await this.pathExists(target.filePath)
if (!exists) return this.inspectMissingTarget(target)
let raw: string
try {
raw = await fs.readFile(target.filePath, 'utf-8')
} catch (error) {
return this.inspectUnreadableTarget(target, error)
}
const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0)
let invalidLineCount = 0
for (const line of lines) {
try {
JSON.parse(line)
} catch {
invalidLineCount += 1
}
}
return {
...this.baseItem(target),
exists: true,
status: invalidLineCount > 0 ? 'invalid_jsonl' : 'ok',
bytes: Buffer.byteLength(raw, 'utf-8'),
lineCount: lines.length,
invalidLineCount,
}
}
private async inspectDirectoryTarget(target: DoctorTarget): Promise<DoctorReportItem> {
const exists = await this.pathExists(target.filePath)
if (!exists) return this.inspectMissingTarget(target)
let entries: Dirent[]
try {
entries = await fs.readdir(target.filePath, { withFileTypes: true })
} catch (error) {
return this.inspectUnreadableTarget(target, error)
}
return {
...this.baseItem(target),
exists: true,
status: 'ok',
bytes: 0,
entryCount: countVisibleEntries(entries),
}
}
private inspectMissingTarget(target: DoctorTarget): DoctorReportItem {
return {
...this.baseItem(target),
exists: false,
status: 'missing',
bytes: 0,
}
}
private inspectUnreadableTarget(target: DoctorTarget, error: unknown): DoctorReportItem {
return {
...this.baseItem(target),
exists: true,
status: 'unreadable',
bytes: 0,
error: this.sanitizeText(error instanceof Error ? error.message : String(error)),
}
}
private baseItem(target: DoctorTarget): Omit<DoctorReportItem, 'exists' | 'status' | 'bytes'> {
return {
id: target.id,
label: target.label,
kind: target.kind,
scope: target.scope,
path: this.sanitizePath(target.filePath),
protected: target.protected,
}
}
private sanitizePath(filePath: string): string {
if (this.projectRoot && isWithinRoot(filePath, this.projectRoot)) {
return this.withAlias('<project>', filePath, this.projectRoot)
}
if (isWithinRoot(filePath, this.configDir)) {
return this.withAlias('~/.claude', filePath, this.configDir)
}
if (isWithinRoot(filePath, this.homeDir)) {
return this.withAlias('~', filePath, this.homeDir)
}
return this.sanitizeText(filePath)
}
private sanitizeText(value: string): string {
let sanitized = diagnosticsService.sanitizeString(value)
const replacements: Array<[string | undefined, string]> = [
[this.projectRoot, '<project>'],
[this.configDir, '~/.claude'],
[this.homeDir, '~'],
]
for (const [from, to] of replacements) {
if (!from) continue
sanitized = sanitized.split(from).join(to)
}
return sanitized
}
private getUserMcpConfigPath(): string {
if (this.usesConfigDirOverride) {
return path.join(this.configDir, '.claude.json')
}
return path.join(this.homeDir, '.claude.json')
}
private async listJsonlFiles(rootDir: string): Promise<string[]> {
const exists = await this.pathExists(rootDir)
if (!exists) return []
const results: string[] = []
const stack = [rootDir]
while (stack.length > 0) {
const current = stack.pop()
if (!current) continue
let entries: Dirent[]
try {
entries = await fs.readdir(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name)
if (entry.isDirectory()) {
stack.push(fullPath)
continue
}
if (entry.isFile() && fullPath.endsWith('.jsonl')) {
results.push(fullPath)
}
}
}
results.sort((left, right) => left.localeCompare(right))
return results
}
private async pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
private jsonTarget(
id: string,
label: string,
scope: 'user' | 'project',
filePath: string,
): DoctorTarget {
return { id, label, kind: 'json', scope, filePath, protected: true }
}
private jsonlTarget(
id: string,
label: string,
scope: 'user' | 'project',
filePath: string,
): DoctorTarget {
return { id, label, kind: 'jsonl', scope, filePath, protected: true }
}
private directoryTarget(
id: string,
label: string,
scope: 'user' | 'project',
filePath: string,
): DoctorTarget {
return { id, label, kind: 'directory', scope, filePath, protected: true }
}
private withAlias(alias: string, filePath: string, root: string): string {
const relativePath = path.relative(root, filePath)
if (!relativePath) return alias
return `${alias}/${toPosix(relativePath)}`
}
}
function countJsonEntries(value: unknown): number {
if (Array.isArray(value)) return value.length
if (value && typeof value === 'object') return Object.keys(value as Record<string, unknown>).length
if (value === null || value === undefined) return 0
return 1
}
function countVisibleEntries(entries: Dirent[]): number {
return entries.filter((entry) => entry.name !== '.DS_Store').length
}
function inferHomeDir(configDir: string): string {
if (path.basename(configDir) === '.claude') {
return path.dirname(configDir)
}
return os.homedir()
}
function isWithinRoot(filePath: string, root: string): boolean {
const relativePath = path.relative(root, filePath)
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))
}
function toPosix(value: string): string {
return value.split(path.sep).join('/')
}