cc-haha/src/server/services/managedSettingsService.ts
程序员阿江(Relakkes) 3276a19da0 Prevent managed settings races from dropping H5 or provider state
Move cc-haha managed settings writes behind one shared transaction helper so
H5 access mutations and provider settings sync serialize against the same file.
Also treat persisted H5 state without a valid token hash as disabled so origin
allowlists cannot remain active after partial corruption.

Constraint: H5 and provider config both share ~/.claude/cc-haha/settings.json
Rejected: Keep per-service locks | concurrent read-modify-write still drops unrelated fields
Rejected: Only harden isOriginAllowed | corrupted enabled state would still surface as active in settings reads
Confidence: high
Scope-risk: narrow
Directive: Any future writer of cc-haha/settings.json must go through the shared managed settings transaction path
Tested: bun test src/server/__tests__/h5-access-service.test.ts src/server/__tests__/h5-access-api.test.ts src/server/__tests__/providers.test.ts src/server/__tests__/provider-presets.test.ts
Not-tested: Full repo typecheck remains blocked by the existing bun-types/tsconfig environment issue in this worktree
2026-05-09 23:32:39 +08:00

84 lines
2.7 KiB
TypeScript

import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { randomBytes } from 'node:crypto'
import { ApiError } from '../middleware/errorHandler.js'
import { normalizeJsonObject, readRecoverableJsonFile } from './recoverableJsonFile.js'
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
export class ManagedSettingsService {
private static writeLocks = new Map<string, Promise<void>>()
private getConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
private getSettingsPath(): string {
return path.join(this.getConfigDir(), 'cc-haha', 'settings.json')
}
private async withWriteLock<T>(
filePath: string,
task: () => Promise<T>,
): Promise<T> {
const previousWrite = ManagedSettingsService.writeLocks.get(filePath) ?? Promise.resolve()
const nextWrite = previousWrite.catch(() => {}).then(task)
const trackedWrite = nextWrite.then(() => {}, () => {})
ManagedSettingsService.writeLocks.set(filePath, trackedWrite)
try {
return await nextWrite
} finally {
if (ManagedSettingsService.writeLocks.get(filePath) === trackedWrite) {
ManagedSettingsService.writeLocks.delete(filePath)
}
}
}
private async writeSettings(settings: Record<string, unknown>): Promise<void> {
const filePath = this.getSettingsPath()
const dir = path.dirname(filePath)
const contents = JSON.stringify(settings, null, 2) + '\n'
const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomBytes(6).toString('hex')}`
await fs.mkdir(dir, { recursive: true })
try {
await fs.writeFile(tmpFile, contents, 'utf-8')
await fs.rename(tmpFile, filePath)
} catch (error) {
await fs.unlink(tmpFile).catch(() => {})
throw ApiError.internal(`Failed to write settings.json: ${error}`)
}
}
async readSettings(): Promise<Record<string, unknown>> {
await ensurePersistentStorageUpgraded()
return readRecoverableJsonFile({
filePath: this.getSettingsPath(),
label: 'cc-haha managed settings',
defaultValue: {},
normalize: normalizeJsonObject,
})
}
async updateSettings<T>(
updater: (current: Record<string, unknown>) => Promise<{
settings: Record<string, unknown>
result: T
}> | {
settings: Record<string, unknown>
result: T
},
): Promise<T> {
const filePath = this.getSettingsPath()
return this.withWriteLock(filePath, async () => {
const current = await this.readSettings()
const { settings, result } = await updater(current)
await this.writeSettings(settings)
return result
})
}
}