mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(config): add CLI privacy gates
This commit is contained in:
1 parent
32e0868f65
commit
217e765305
5 files changed
+276
-1
No files matched your search
@@ -5,8 +5,10 @@
|
||||
*/
|
||||
|
||||
export { loadConfig, getConfigPath, getConfigDir, writeConfigField } from './loader'
|
||||
export { setConfigField, ConfigSetError } from './set-config-field'
|
||||
export type { ConfigSetResult, ConfigSetErrorReason } from './set-config-field'
|
||||
export { configSchema, DEFAULT_API_PORT } from './schema'
|
||||
export type { ChatLabConfig, LlmConfig, DataConfig, ApiConfig, LocaleConfig, UiConfig } from './schema'
|
||||
export type { ChatLabConfig, LlmConfig, DataConfig, ApiConfig, LocaleConfig, UiConfig, CliConfig } from './schema'
|
||||
export {
|
||||
loadAuthProfiles,
|
||||
getApiKeyByProfile,
|
||||
|
||||
@@ -80,6 +80,7 @@ function loadConfigFile(): Record<string, unknown> {
|
||||
* - CHATLAB_LLM_MODEL -> llm.model
|
||||
* - CHATLAB_LLM_BASE_URL -> llm.base_url
|
||||
* - CHATLAB_LOCALE_LANG -> locale.lang
|
||||
* - CHATLAB_CLI_ALLOW_RAW -> cli.allow_raw ('1'/'true' enables)
|
||||
*/
|
||||
function loadEnvConfig(): Record<string, unknown> {
|
||||
const result: Record<string, Record<string, unknown>> = {}
|
||||
@@ -92,6 +93,7 @@ function loadEnvConfig(): Record<string, unknown> {
|
||||
{ env: 'CHATLAB_LLM_MODEL', section: 'llm', key: 'model' },
|
||||
{ env: 'CHATLAB_LLM_BASE_URL', section: 'llm', key: 'base_url' },
|
||||
{ env: 'CHATLAB_LOCALE_LANG', section: 'locale', key: 'lang' },
|
||||
{ env: 'CHATLAB_CLI_ALLOW_RAW', section: 'cli', key: 'allow_raw', transform: (v) => v === '1' || v === 'true' },
|
||||
]
|
||||
|
||||
for (const { env, section, key, transform } of envMap) {
|
||||
|
||||
@@ -36,12 +36,20 @@ export const uiConfigSchema = z.object({
|
||||
summary_strategy: z.enum(['brief', 'standard']).default('standard'),
|
||||
})
|
||||
|
||||
export const cliConfigSchema = z.object({
|
||||
/** Allow --raw output that bypasses privacy preprocessing (user-side opt-in). */
|
||||
allow_raw: z.boolean().default(false),
|
||||
/** Allow the read-only `sql` query command. */
|
||||
allow_sql: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const configSchema = z.object({
|
||||
llm: llmConfigSchema.default({}),
|
||||
data: dataConfigSchema.default({}),
|
||||
api: apiConfigSchema.default({}),
|
||||
locale: localeConfigSchema.default({}),
|
||||
ui: uiConfigSchema.default({}),
|
||||
cli: cliConfigSchema.default({}),
|
||||
})
|
||||
|
||||
export type ChatLabConfig = z.infer<typeof configSchema>
|
||||
@@ -50,3 +58,4 @@ export type DataConfig = z.infer<typeof dataConfigSchema>
|
||||
export type ApiConfig = z.infer<typeof apiConfigSchema>
|
||||
export type LocaleConfig = z.infer<typeof localeConfigSchema>
|
||||
export type UiConfig = z.infer<typeof uiConfigSchema>
|
||||
export type CliConfig = z.infer<typeof cliConfigSchema>
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { after, beforeEach, describe, it } from 'node:test'
|
||||
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'chatlab-config-set-'))
|
||||
process.env.HOME = tempHome
|
||||
|
||||
const [{ setConfigField, ConfigSetError }, { loadConfig }] = await Promise.all([
|
||||
import('./set-config-field'),
|
||||
import('./loader'),
|
||||
])
|
||||
|
||||
const configToml = join(tempHome, '.chatlab', 'config.toml')
|
||||
|
||||
beforeEach(() => {
|
||||
rmSync(join(tempHome, '.chatlab'), { recursive: true, force: true })
|
||||
delete process.env.CHATLAB_CLI_ALLOW_RAW
|
||||
})
|
||||
|
||||
after(() => {
|
||||
rmSync(tempHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('cli config section', () => {
|
||||
it('defaults allow_raw=false and allow_sql=true', () => {
|
||||
const config = loadConfig()
|
||||
assert.equal(config.cli.allow_raw, false)
|
||||
assert.equal(config.cli.allow_sql, true)
|
||||
})
|
||||
|
||||
it('maps CHATLAB_CLI_ALLOW_RAW env var to cli.allow_raw', () => {
|
||||
process.env.CHATLAB_CLI_ALLOW_RAW = '1'
|
||||
assert.equal(loadConfig().cli.allow_raw, true)
|
||||
|
||||
process.env.CHATLAB_CLI_ALLOW_RAW = 'true'
|
||||
assert.equal(loadConfig().cli.allow_raw, true)
|
||||
|
||||
process.env.CHATLAB_CLI_ALLOW_RAW = '0'
|
||||
assert.equal(loadConfig().cli.allow_raw, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setConfigField', () => {
|
||||
it('writes boolean value and round-trips via loadConfig', () => {
|
||||
const result = setConfigField('cli.allow_raw', 'true')
|
||||
assert.deepEqual(result, { section: 'cli', key: 'allow_raw', value: true })
|
||||
assert.equal(loadConfig().cli.allow_raw, true)
|
||||
|
||||
setConfigField('cli.allow_raw', 'false')
|
||||
assert.equal(loadConfig().cli.allow_raw, false)
|
||||
})
|
||||
|
||||
it('parses number values by schema type', () => {
|
||||
setConfigField('api.port', '8080')
|
||||
assert.equal(loadConfig().api.port, 8080)
|
||||
})
|
||||
|
||||
it('rejects unknown section or key without touching the file', () => {
|
||||
assert.throws(
|
||||
() => setConfigField('nope.key', 'x'),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof ConfigSetError)
|
||||
assert.equal(err.reason, 'unknown_key')
|
||||
return true
|
||||
}
|
||||
)
|
||||
assert.throws(
|
||||
() => setConfigField('cli.nope', 'x'),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof ConfigSetError)
|
||||
assert.equal(err.reason, 'unknown_key')
|
||||
return true
|
||||
}
|
||||
)
|
||||
assert.throws(
|
||||
() => setConfigField('cli', 'x'),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof ConfigSetError)
|
||||
assert.equal(err.reason, 'unknown_key')
|
||||
return true
|
||||
}
|
||||
)
|
||||
assert.equal(existsSync(configToml), false)
|
||||
})
|
||||
|
||||
it('rejects values that do not parse as the schema type', () => {
|
||||
assert.throws(
|
||||
() => setConfigField('cli.allow_raw', 'yes'),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof ConfigSetError)
|
||||
assert.equal(err.reason, 'invalid_value')
|
||||
return true
|
||||
}
|
||||
)
|
||||
assert.throws(
|
||||
() => setConfigField('api.port', 'not-a-number'),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof ConfigSetError)
|
||||
assert.equal(err.reason, 'invalid_value')
|
||||
return true
|
||||
}
|
||||
)
|
||||
assert.equal(existsSync(configToml), false)
|
||||
})
|
||||
|
||||
it('rolls back when post-write validation fails', () => {
|
||||
setConfigField('cli.allow_raw', 'true')
|
||||
const original = readFileSync(configToml, 'utf-8')
|
||||
|
||||
// typeof default is string, passes type parsing, but violates the zod enum on reload
|
||||
assert.throws(
|
||||
() => setConfigField('ui.default_session_tab', 'bogus'),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof ConfigSetError)
|
||||
assert.equal(err.reason, 'invalid_config')
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(readFileSync(configToml, 'utf-8'), original)
|
||||
assert.equal(loadConfig().cli.allow_raw, true)
|
||||
})
|
||||
|
||||
it('removes the file on rollback when it did not exist before', () => {
|
||||
assert.throws(
|
||||
() => setConfigField('ui.default_session_tab', 'bogus'),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof ConfigSetError)
|
||||
assert.equal(err.reason, 'invalid_config')
|
||||
return true
|
||||
}
|
||||
)
|
||||
assert.equal(existsSync(configToml), false)
|
||||
})
|
||||
|
||||
it('refuses to overwrite an unparseable config file', () => {
|
||||
mkdirSync(join(tempHome, '.chatlab'), { recursive: true })
|
||||
const corrupt = '[cli\nallow_raw ='
|
||||
writeFileSync(configToml, corrupt, 'utf-8')
|
||||
|
||||
assert.throws(
|
||||
() => setConfigField('cli.allow_raw', 'true'),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof ConfigSetError)
|
||||
assert.equal(err.reason, 'unreadable_config')
|
||||
return true
|
||||
}
|
||||
)
|
||||
assert.equal(readFileSync(configToml, 'utf-8'), corrupt)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Safe config field writer for `chatlab config set`.
|
||||
*
|
||||
* Wraps writeConfigField with guardrails: only schema-defined keys are accepted,
|
||||
* values are parsed by schema type, and the file is validated after writing with
|
||||
* rollback on failure — a broken config file is a user-data incident.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { parse as parseToml } from 'smol-toml'
|
||||
import { configSchema } from './schema'
|
||||
import { loadConfig, writeConfigField, getConfigDir } from './loader'
|
||||
|
||||
export type ConfigSetErrorReason = 'unknown_key' | 'invalid_value' | 'invalid_config' | 'unreadable_config'
|
||||
|
||||
export class ConfigSetError extends Error {
|
||||
readonly reason: ConfigSetErrorReason
|
||||
|
||||
constructor(reason: ConfigSetErrorReason, message: string) {
|
||||
super(message)
|
||||
this.name = 'ConfigSetError'
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigSetResult {
|
||||
section: string
|
||||
key: string
|
||||
value: string | number | boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a config field addressed as `<section>.<key>` (e.g. `cli.allow_raw`).
|
||||
* Throws ConfigSetError without touching the file when the key or value is invalid;
|
||||
* rolls the file back when the written config fails full validation.
|
||||
*/
|
||||
export function setConfigField(fieldPath: string, rawValue: string): ConfigSetResult {
|
||||
const parts = fieldPath.split('.')
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||
throw new ConfigSetError('unknown_key', `Expected <section>.<key>, got "${fieldPath}"`)
|
||||
}
|
||||
const [section, key] = parts
|
||||
|
||||
const defaults = configSchema.parse({}) as Record<string, Record<string, unknown>>
|
||||
const sectionDefaults = defaults[section]
|
||||
if (!sectionDefaults || typeof sectionDefaults !== 'object') {
|
||||
throw new ConfigSetError('unknown_key', `Unknown config section "${section}"`)
|
||||
}
|
||||
if (!(key in sectionDefaults)) {
|
||||
throw new ConfigSetError('unknown_key', `Unknown config key "${section}.${key}"`)
|
||||
}
|
||||
|
||||
const value = parseValueByType(section, key, typeof sectionDefaults[key], rawValue)
|
||||
|
||||
const tomlPath = path.join(getConfigDir(), 'config.toml')
|
||||
let original: string | null = null
|
||||
if (fs.existsSync(tomlPath)) {
|
||||
original = fs.readFileSync(tomlPath, 'utf-8')
|
||||
try {
|
||||
parseToml(original)
|
||||
} catch {
|
||||
throw new ConfigSetError(
|
||||
'unreadable_config',
|
||||
`Existing config file is not valid TOML and will not be overwritten: ${tomlPath}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
writeConfigField(section, key, value)
|
||||
|
||||
try {
|
||||
loadConfig()
|
||||
} catch (err) {
|
||||
if (original === null) {
|
||||
fs.unlinkSync(tomlPath)
|
||||
} else {
|
||||
fs.writeFileSync(tomlPath, original, 'utf-8')
|
||||
}
|
||||
const detail = err instanceof Error ? err.message : String(err)
|
||||
throw new ConfigSetError(
|
||||
'invalid_config',
|
||||
`Value "${rawValue}" for ${section}.${key} failed config validation; file rolled back. ${detail}`
|
||||
)
|
||||
}
|
||||
|
||||
return { section, key, value }
|
||||
}
|
||||
|
||||
function parseValueByType(
|
||||
section: string,
|
||||
key: string,
|
||||
expectedType: string,
|
||||
rawValue: string
|
||||
): string | number | boolean {
|
||||
if (expectedType === 'boolean') {
|
||||
if (rawValue === 'true') return true
|
||||
if (rawValue === 'false') return false
|
||||
throw new ConfigSetError('invalid_value', `${section}.${key} expects "true" or "false", got "${rawValue}"`)
|
||||
}
|
||||
if (expectedType === 'number') {
|
||||
const num = Number(rawValue)
|
||||
if (!Number.isFinite(num) || rawValue.trim() === '') {
|
||||
throw new ConfigSetError('invalid_value', `${section}.${key} expects a number, got "${rawValue}"`)
|
||||
}
|
||||
return num
|
||||
}
|
||||
return rawValue
|
||||
}
|
||||
Reference in new issue
Block a user