mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-26 15:03:34 +08:00
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
This commit is contained in:
parent
e357c978fe
commit
3276a19da0
@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises'
|
|||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { H5AccessService } from '../services/h5AccessService.js'
|
import { H5AccessService } from '../services/h5AccessService.js'
|
||||||
|
import { ProviderService } from '../services/providerService.js'
|
||||||
|
|
||||||
let tmpDir: string
|
let tmpDir: string
|
||||||
let originalConfigDir: string | undefined
|
let originalConfigDir: string | undefined
|
||||||
@ -154,4 +155,58 @@ describe('H5AccessService', () => {
|
|||||||
await expect(service.isOriginAllowed('https://other.example.com')).resolves.toBe(false)
|
await expect(service.isOriginAllowed('https://other.example.com')).resolves.toBe(false)
|
||||||
await expect(service.isOriginAllowed('notaurl')).resolves.toBe(false)
|
await expect(service.isOriginAllowed('notaurl')).resolves.toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('malformed persisted enabled state without token hash is treated as disabled', async () => {
|
||||||
|
await fs.mkdir(path.dirname(getManagedSettingsPath()), { recursive: true })
|
||||||
|
await fs.writeFile(
|
||||||
|
getManagedSettingsPath(),
|
||||||
|
JSON.stringify({
|
||||||
|
h5Access: {
|
||||||
|
enabled: true,
|
||||||
|
allowedOrigins: ['https://example.com/path'],
|
||||||
|
publicBaseUrl: 'https://public.example.com',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf-8',
|
||||||
|
)
|
||||||
|
|
||||||
|
const service = new H5AccessService()
|
||||||
|
|
||||||
|
await expect(service.getSettings()).resolves.toEqual({
|
||||||
|
enabled: false,
|
||||||
|
tokenPreview: null,
|
||||||
|
allowedOrigins: ['https://example.com'],
|
||||||
|
publicBaseUrl: 'https://public.example.com',
|
||||||
|
})
|
||||||
|
await expect(service.validateToken('anything')).resolves.toBe(false)
|
||||||
|
await expect(service.isOriginAllowed('https://example.com')).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('concurrent h5 enable and provider managed settings update preserve both fields', async () => {
|
||||||
|
const h5Service = new H5AccessService()
|
||||||
|
const providerService = new ProviderService()
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
h5Service.enable(),
|
||||||
|
providerService.updateManagedSettings({
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_MODEL: 'keep-me',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const saved = JSON.parse(await fs.readFile(getManagedSettingsPath(), 'utf-8')) as {
|
||||||
|
env?: {
|
||||||
|
ANTHROPIC_MODEL?: string
|
||||||
|
}
|
||||||
|
h5Access?: {
|
||||||
|
enabled?: boolean
|
||||||
|
tokenHash?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(saved.env?.ANTHROPIC_MODEL).toBe('keep-me')
|
||||||
|
expect(saved.h5Access?.enabled).toBe(true)
|
||||||
|
expect(saved.h5Access?.tokenHash).toEqual(expect.any(String))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
import * as fs from 'node:fs/promises'
|
|
||||||
import * as os from 'node:os'
|
|
||||||
import * as path from 'node:path'
|
|
||||||
import { createHash, randomBytes } from 'node:crypto'
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
import { ApiError } from '../middleware/errorHandler.js'
|
import { ApiError } from '../middleware/errorHandler.js'
|
||||||
import { normalizeJsonObject, readRecoverableJsonFile } from './recoverableJsonFile.js'
|
import { ManagedSettingsService } from './managedSettingsService.js'
|
||||||
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
|
|
||||||
|
|
||||||
export type H5AccessSettings = {
|
export type H5AccessSettings = {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@ -30,6 +26,8 @@ const DEFAULT_STORED_SETTINGS: StoredH5AccessSettings = {
|
|||||||
publicBaseUrl: null,
|
publicBaseUrl: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOKEN_HASH_RE = /^[a-f0-9]{64}$/
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
@ -149,97 +147,40 @@ function normalizeStoredSettings(value: unknown): StoredH5AccessSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenHash = typeof value.tokenHash === 'string' && TOKEN_HASH_RE.test(value.tokenHash)
|
||||||
|
? value.tokenHash
|
||||||
|
: null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: value.enabled === true,
|
enabled: value.enabled === true && tokenHash !== null,
|
||||||
tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : null,
|
tokenHash,
|
||||||
tokenPreview: typeof value.tokenPreview === 'string' ? value.tokenPreview : null,
|
tokenPreview: tokenHash && typeof value.tokenPreview === 'string' ? value.tokenPreview : null,
|
||||||
allowedOrigins,
|
allowedOrigins,
|
||||||
publicBaseUrl,
|
publicBaseUrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class H5AccessService {
|
export class H5AccessService {
|
||||||
private static writeLocks = new Map<string, Promise<void>>()
|
private managedSettingsService = new ManagedSettingsService()
|
||||||
|
|
||||||
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 = H5AccessService.writeLocks.get(filePath) ?? Promise.resolve()
|
|
||||||
const nextWrite = previousWrite.catch(() => {}).then(task)
|
|
||||||
const trackedWrite = nextWrite.then(() => {}, () => {})
|
|
||||||
|
|
||||||
H5AccessService.writeLocks.set(filePath, trackedWrite)
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await nextWrite
|
|
||||||
} finally {
|
|
||||||
if (H5AccessService.writeLocks.get(filePath) === trackedWrite) {
|
|
||||||
H5AccessService.writeLocks.delete(filePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readManagedSettings(): Promise<Record<string, unknown>> {
|
|
||||||
await ensurePersistentStorageUpgraded()
|
|
||||||
return readRecoverableJsonFile({
|
|
||||||
filePath: this.getSettingsPath(),
|
|
||||||
label: 'cc-haha managed settings',
|
|
||||||
defaultValue: {},
|
|
||||||
normalize: normalizeJsonObject,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async writeManagedSettings(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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readStoredSettings(): Promise<{
|
private async readStoredSettings(): Promise<{
|
||||||
managedSettings: Record<string, unknown>
|
managedSettings: Record<string, unknown>
|
||||||
h5Access: StoredH5AccessSettings
|
h5Access: StoredH5AccessSettings
|
||||||
}> {
|
}> {
|
||||||
const managedSettings = await this.readManagedSettings()
|
const managedSettings = await this.managedSettingsService.readSettings()
|
||||||
return {
|
return {
|
||||||
managedSettings,
|
managedSettings,
|
||||||
h5Access: normalizeStoredSettings(managedSettings.h5Access),
|
h5Access: normalizeStoredSettings(managedSettings.h5Access),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async saveStoredSettings(
|
|
||||||
managedSettings: Record<string, unknown>,
|
|
||||||
h5Access: StoredH5AccessSettings,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.writeManagedSettings({
|
|
||||||
...managedSettings,
|
|
||||||
h5Access,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async setToken(
|
private async setToken(
|
||||||
managedSettings: Record<string, unknown>,
|
managedSettings: Record<string, unknown>,
|
||||||
current: StoredH5AccessSettings,
|
current: StoredH5AccessSettings,
|
||||||
): Promise<H5AccessEnableResult> {
|
): Promise<{
|
||||||
|
settings: Record<string, unknown>
|
||||||
|
result: H5AccessEnableResult
|
||||||
|
}> {
|
||||||
const token = createToken()
|
const token = createToken()
|
||||||
const nextSettings: StoredH5AccessSettings = {
|
const nextSettings: StoredH5AccessSettings = {
|
||||||
...current,
|
...current,
|
||||||
@ -248,11 +189,15 @@ export class H5AccessService {
|
|||||||
tokenPreview: createTokenPreview(token),
|
tokenPreview: createTokenPreview(token),
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveStoredSettings(managedSettings, nextSettings)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
settings: {
|
||||||
|
...managedSettings,
|
||||||
|
h5Access: nextSettings,
|
||||||
|
},
|
||||||
|
result: {
|
||||||
settings: toPublicSettings(nextSettings),
|
settings: toPublicSettings(nextSettings),
|
||||||
token,
|
token,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,15 +207,14 @@ export class H5AccessService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async enable(): Promise<H5AccessEnableResult> {
|
async enable(): Promise<H5AccessEnableResult> {
|
||||||
return this.withWriteLock(this.getSettingsPath(), async () => {
|
return this.managedSettingsService.updateSettings(async (current) => {
|
||||||
const { managedSettings, h5Access } = await this.readStoredSettings()
|
return this.setToken(current, normalizeStoredSettings(current.h5Access))
|
||||||
return this.setToken(managedSettings, h5Access)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async disable(): Promise<H5AccessSettings> {
|
async disable(): Promise<H5AccessSettings> {
|
||||||
return this.withWriteLock(this.getSettingsPath(), async () => {
|
return this.managedSettingsService.updateSettings(async (current) => {
|
||||||
const { managedSettings, h5Access } = await this.readStoredSettings()
|
const h5Access = normalizeStoredSettings(current.h5Access)
|
||||||
const nextSettings: StoredH5AccessSettings = {
|
const nextSettings: StoredH5AccessSettings = {
|
||||||
...h5Access,
|
...h5Access,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@ -278,15 +222,19 @@ export class H5AccessService {
|
|||||||
tokenPreview: null,
|
tokenPreview: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveStoredSettings(managedSettings, nextSettings)
|
return {
|
||||||
return toPublicSettings(nextSettings)
|
settings: {
|
||||||
|
...current,
|
||||||
|
h5Access: nextSettings,
|
||||||
|
},
|
||||||
|
result: toPublicSettings(nextSettings),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async regenerateToken(): Promise<H5AccessEnableResult> {
|
async regenerateToken(): Promise<H5AccessEnableResult> {
|
||||||
return this.withWriteLock(this.getSettingsPath(), async () => {
|
return this.managedSettingsService.updateSettings(async (current) => {
|
||||||
const { managedSettings, h5Access } = await this.readStoredSettings()
|
return this.setToken(current, normalizeStoredSettings(current.h5Access))
|
||||||
return this.setToken(managedSettings, h5Access)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -294,8 +242,8 @@ export class H5AccessService {
|
|||||||
allowedOrigins?: string[]
|
allowedOrigins?: string[]
|
||||||
publicBaseUrl?: string | null
|
publicBaseUrl?: string | null
|
||||||
}): Promise<H5AccessSettings> {
|
}): Promise<H5AccessSettings> {
|
||||||
return this.withWriteLock(this.getSettingsPath(), async () => {
|
return this.managedSettingsService.updateSettings(async (current) => {
|
||||||
const { managedSettings, h5Access } = await this.readStoredSettings()
|
const h5Access = normalizeStoredSettings(current.h5Access)
|
||||||
const nextSettings: StoredH5AccessSettings = {
|
const nextSettings: StoredH5AccessSettings = {
|
||||||
...h5Access,
|
...h5Access,
|
||||||
allowedOrigins: input.allowedOrigins === undefined
|
allowedOrigins: input.allowedOrigins === undefined
|
||||||
@ -306,8 +254,13 @@ export class H5AccessService {
|
|||||||
: normalizePublicBaseUrl(input.publicBaseUrl),
|
: normalizePublicBaseUrl(input.publicBaseUrl),
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.saveStoredSettings(managedSettings, nextSettings)
|
return {
|
||||||
return toPublicSettings(nextSettings)
|
settings: {
|
||||||
|
...current,
|
||||||
|
h5Access: nextSettings,
|
||||||
|
},
|
||||||
|
result: toPublicSettings(nextSettings),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
83
src/server/services/managedSettingsService.ts
Normal file
83
src/server/services/managedSettingsService.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,7 +10,8 @@ import * as fs from 'fs/promises'
|
|||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as os from 'os'
|
import * as os from 'os'
|
||||||
import { ApiError } from '../middleware/errorHandler.js'
|
import { ApiError } from '../middleware/errorHandler.js'
|
||||||
import { normalizeJsonObject, readRecoverableJsonFile } from './recoverableJsonFile.js'
|
import { readRecoverableJsonFile } from './recoverableJsonFile.js'
|
||||||
|
import { ManagedSettingsService } from './managedSettingsService.js'
|
||||||
import { anthropicToOpenaiChat } from '../proxy/transform/anthropicToOpenaiChat.js'
|
import { anthropicToOpenaiChat } from '../proxy/transform/anthropicToOpenaiChat.js'
|
||||||
import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js'
|
import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js'
|
||||||
import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.js'
|
import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.js'
|
||||||
@ -171,6 +172,7 @@ function getManagedEnvKeys(): string[] {
|
|||||||
|
|
||||||
export class ProviderService {
|
export class ProviderService {
|
||||||
private static serverPort = 3456
|
private static serverPort = 3456
|
||||||
|
private managedSettingsService = new ManagedSettingsService()
|
||||||
|
|
||||||
static setServerPort(port: number): void {
|
static setServerPort(port: number): void {
|
||||||
ProviderService.serverPort = port
|
ProviderService.serverPort = port
|
||||||
@ -191,10 +193,6 @@ export class ProviderService {
|
|||||||
return path.join(this.getCcHahaDir(), 'providers.json')
|
return path.join(this.getCcHahaDir(), 'providers.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSettingsPath(): string {
|
|
||||||
return path.join(this.getCcHahaDir(), 'settings.json')
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readIndex(): Promise<ProvidersIndex> {
|
private async readIndex(): Promise<ProvidersIndex> {
|
||||||
await ensurePersistentStorageUpgraded()
|
await ensurePersistentStorageUpgraded()
|
||||||
return readRecoverableJsonFile({
|
return readRecoverableJsonFile({
|
||||||
@ -221,28 +219,7 @@ export class ProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async readSettings(): Promise<Record<string, unknown>> {
|
private async readSettings(): Promise<Record<string, unknown>> {
|
||||||
await ensurePersistentStorageUpgraded()
|
return this.managedSettingsService.readSettings()
|
||||||
return readRecoverableJsonFile({
|
|
||||||
filePath: this.getSettingsPath(),
|
|
||||||
label: 'cc-haha managed settings',
|
|
||||||
defaultValue: {},
|
|
||||||
normalize: normalizeJsonObject,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async writeSettings(settings: Record<string, unknown>): Promise<void> {
|
|
||||||
const filePath = this.getSettingsPath()
|
|
||||||
const dir = path.dirname(filePath)
|
|
||||||
await fs.mkdir(dir, { recursive: true })
|
|
||||||
|
|
||||||
const tmpFile = `${filePath}.tmp.${Date.now()}`
|
|
||||||
try {
|
|
||||||
await fs.writeFile(tmpFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
|
|
||||||
await fs.rename(tmpFile, filePath)
|
|
||||||
} catch (err) {
|
|
||||||
await fs.unlink(tmpFile).catch(() => {})
|
|
||||||
throw ApiError.internal(`Failed to write settings.json: ${err}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getManagedSettings(): Promise<Record<string, unknown>> {
|
async getManagedSettings(): Promise<Record<string, unknown>> {
|
||||||
@ -250,8 +227,10 @@ export class ProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateManagedSettings(settings: Record<string, unknown>): Promise<void> {
|
async updateManagedSettings(settings: Record<string, unknown>): Promise<void> {
|
||||||
const current = await this.readSettings()
|
await this.managedSettingsService.updateSettings((current) => ({
|
||||||
await this.writeSettings(Object.assign({}, current, settings))
|
settings: Object.assign({}, current, settings),
|
||||||
|
result: undefined,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CRUD ---
|
// --- CRUD ---
|
||||||
@ -415,7 +394,7 @@ export class ProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async syncToSettings(provider: SavedProvider): Promise<void> {
|
private async syncToSettings(provider: SavedProvider): Promise<void> {
|
||||||
const settings = await this.readSettings()
|
await this.managedSettingsService.updateSettings((settings) => {
|
||||||
const existingEnv = (settings.env as Record<string, string>) || {}
|
const existingEnv = (settings.env as Record<string, string>) || {}
|
||||||
const cleanedEnv = { ...existingEnv }
|
const cleanedEnv = { ...existingEnv }
|
||||||
|
|
||||||
@ -423,28 +402,42 @@ export class ProviderService {
|
|||||||
delete cleanedEnv[key]
|
delete cleanedEnv[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.env = {
|
return {
|
||||||
|
settings: {
|
||||||
|
...settings,
|
||||||
|
env: {
|
||||||
...cleanedEnv,
|
...cleanedEnv,
|
||||||
...this.buildManagedEnv(provider),
|
...this.buildManagedEnv(provider),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
result: undefined,
|
||||||
}
|
}
|
||||||
|
})
|
||||||
await this.writeSettings(settings)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearProviderFromSettings(): Promise<void> {
|
private async clearProviderFromSettings(): Promise<void> {
|
||||||
const settings = await this.readSettings()
|
await this.managedSettingsService.updateSettings((settings) => {
|
||||||
const env = (settings.env as Record<string, string>) || {}
|
const env = { ...((settings.env as Record<string, string>) || {}) }
|
||||||
|
|
||||||
for (const key of getManagedEnvKeys()) {
|
for (const key of getManagedEnvKeys()) {
|
||||||
delete env[key]
|
delete env[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.env = env
|
const nextSettings: Record<string, unknown> = {
|
||||||
if (Object.keys(env).length === 0) {
|
...settings,
|
||||||
delete settings.env
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.writeSettings(settings)
|
if (Object.keys(env).length === 0) {
|
||||||
|
delete nextSettings.env
|
||||||
|
} else {
|
||||||
|
nextSettings.env = env
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: nextSettings,
|
||||||
|
result: undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auth status ---
|
// --- Auth status ---
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user