配置系统:Schema 验证、迁移与多来源合并

深入 Claude Code 的配置架构——Zod Schema、多来源优先级合并、版本化迁移、远程管理配置

问题引入

配置管理听起来简单——读个 JSON 文件不就行了?但当你需要支持以下所有场景时,复杂度会指数级增长:

  • 用户全局设置(~/.claude/settings.json
  • 项目共享设置(.claude/settings.json,提交到 git)
  • 项目本地设置(.claude/settings.local.json,gitignore)
  • CLI 标志覆盖(--settings 参数)
  • 企业管理策略(MDM 推送或远程 API)
  • 远程管理配置(从 API 拉取的组织级配置)
  • 配置变更时的实时热更新
  • 多来源之间的优先级合并
  • 旧配置到新格式的自动迁移

Claude Code 的配置系统用 Zod Schema 验证每一层,用确定性的优先级规则合并 5 个来源,并通过 11 个迁移函数处理历史兼容性。这篇文章深入每一层的实现。

配置来源与优先级

...

src/utils/settings/constants.ts 定义了来源优先级:

TypeScript
1// src/utils/settings/constants.ts 行 7-22
2export const SETTING_SOURCES = [
3 'userSettings', // 用户全局设置
4 'projectSettings', // 项目共享设置
5 'localSettings', // 项目本地设置(gitignored)
6 'flagSettings', // CLI --settings 标志
7 'policySettings', // 企业管理策略
8] as const

顺序就是优先级——后面的覆盖前面的。这意味着:

  1. 用户设置 是基础层
  2. 项目设置 覆盖用户偏好(团队约定)
  3. 本地设置 覆盖项目设置(个人覆盖)
  4. CLI 标志 覆盖一切文件配置(临时覆盖)
  5. 策略设置 最高优先级(企业强制)

来源类型

TypeScript
1// src/utils/settings/constants.ts 行 24, 182-185
2export type SettingSource = (typeof SETTING_SOURCES)[number]
3
4export type EditableSettingSource = Exclude<
5 SettingSource,
6 'policySettings' | 'flagSettings'
7>

EditableSettingSource 排除了策略和标志来源——用户不能编辑管理策略或 CLI 标志生成的配置。只有 userSettingsprojectSettingslocalSettings 可以通过 /config 命令或直接编辑文件来修改。

来源启用控制

TypeScript
1// src/utils/settings/constants.ts 行 159-167
2export function getEnabledSettingSources(): SettingSource[] {
3 const allowed = getAllowedSettingSources()
4 // 策略和标志来源始终启用
5 const result = new Set<SettingSource>(allowed)
6 result.add('policySettings')
7 result.add('flagSettings')
8 return Array.from(result)
9}

即使通过 --setting-sources 限制了来源,策略和标志设置始终生效。这保证了企业管理策略无法被绕过。

Zod Schema 验证

src/utils/settings/types.ts 定义了配置的完整 Schema。

权限 Schema

TypeScript
1// src/utils/settings/types.ts 行 42-85
2export const PermissionsSchema = lazySchema(() =>
3 z.object({
4 allow: z.array(PermissionRuleSchema()).optional()
5 .describe('List of permission rules for allowed operations'),
6 deny: z.array(PermissionRuleSchema()).optional()
7 .describe('List of permission rules for denied operations'),
8 ask: z.array(PermissionRuleSchema()).optional()
9 .describe('List of permission rules that should always prompt'),
10 defaultMode: z.enum(
11 feature('TRANSCRIPT_CLASSIFIER')
12 ? PERMISSION_MODES
13 : EXTERNAL_PERMISSION_MODES,
14 ).optional(),
15 disableBypassPermissionsMode: z.enum(['disable']).optional(),
16 ...(feature('TRANSCRIPT_CLASSIFIER')
17 ? { disableAutoMode: z.enum(['disable']).optional() }
18 : {}),
19 additionalDirectories: z.array(z.string()).optional(),
20 }).passthrough(),
21)

注意两个关键设计:

  1. feature() 编译期条件TRANSCRIPT_CLASSIFIER flag 控制 auto mode 的 Schema 是否包含。外部构建中,disableAutoMode 字段根本不存在于 Schema 中。
  2. .passthrough() — 允许未知字段通过验证,保证前向兼容。未来版本添加新字段不会导致旧版本报错。

Hook Schema

TypeScript
1// src/schemas/hooks.ts 行 32-171(核心部分)
2function buildHookSchemas() {
3 const BashCommandHookSchema = z.object({
4 type: z.literal('command'),
5 command: z.string(),
6 if: IfConditionSchema(),
7 shell: z.enum(SHELL_TYPES).optional(),
8 timeout: z.number().positive().optional(),
9 statusMessage: z.string().optional(),
10 once: z.boolean().optional(),
11 async: z.boolean().optional(),
12 asyncRewake: z.boolean().optional(),
13 })
14
15 const PromptHookSchema = z.object({
16 type: z.literal('prompt'),
17 prompt: z.string(),
18 if: IfConditionSchema(),
19 timeout: z.number().positive().optional(),
20 model: z.string().optional(),
21 statusMessage: z.string().optional(),
22 once: z.boolean().optional(),
23 })
24
25 const HttpHookSchema = z.object({
26 type: z.literal('http'),
27 url: z.string().url(),
28 if: IfConditionSchema(),
29 timeout: z.number().positive().optional(),
30 headers: z.record(z.string(), z.string()).optional(),
31 allowedEnvVars: z.array(z.string()).optional(),
32 statusMessage: z.string().optional(),
33 once: z.boolean().optional(),
34 })
35
36 const AgentHookSchema = z.object({
37 type: z.literal('agent'),
38 prompt: z.string(),
39 if: IfConditionSchema(),
40 timeout: z.number().positive().optional(),
41 model: z.string().optional(),
42 statusMessage: z.string().optional(),
43 once: z.boolean().optional(),
44 })
45
46 return { BashCommandHookSchema, PromptHookSchema, HttpHookSchema, AgentHookSchema }
47}

Hook 使用 Zod 的 discriminatedUnion,以 type 字段区分四种类型:

TypeScript
1// src/schemas/hooks.ts 行 176-189
2export const HookCommandSchema = lazySchema(() => {
3 const { BashCommandHookSchema, PromptHookSchema, AgentHookSchema, HttpHookSchema }
4 = buildHookSchemas()
5 return z.discriminatedUnion('type', [
6 BashCommandHookSchema,
7 PromptHookSchema,
8 AgentHookSchema,
9 HttpHookSchema,
10 ])
11})

lazySchema 模式

注意所有 Schema 都用 lazySchema 包装。这是一个惰性求值包装器——Schema 只在第一次调用时构造,避免模块加载时执行昂贵的 Zod 类型构建。对于 CLI 的启动速度来说,这是重要的优化。

环境变量 Schema

TypeScript
1// src/utils/settings/types.ts 行 35-37
2export const EnvironmentVariablesSchema = lazySchema(() =>
3 z.record(z.string(), z.coerce.string()),
4)

z.coerce.string() 意味着即使值是数字或布尔值,也会被强制转为字符串。这符合环境变量的语义——所有环境变量本质上都是字符串。

配置文件加载与合并

src/utils/settings/settings.ts 实现了配置的读取和合并。

文件 Managed Settings

TypeScript
1// src/utils/settings/settings.ts 行 74-100(loadManagedFileSettings)
2export function loadManagedFileSettings(): {
3 settings: SettingsJson | null
4 errors: ValidationError[]
5} {
6 const errors: ValidationError[] = []
7 let merged: SettingsJson = {}
8 let found = false
9
10 // 1. 加载基础文件
11 const { settings, errors: baseErrors } = parseSettingsFile(
12 getManagedSettingsFilePath()
13 )
14 errors.push(...baseErrors)
15 if (settings && Object.keys(settings).length > 0) {
16 merged = mergeWith(merged, settings, settingsMergeCustomizer)
17 found = true
18 }
19
20 // 2. 加载 drop-in 目录
21 const dropInDir = getManagedSettingsDropInDir()
22 try {
23 const entries = getFsImplementation()
24 .readdirSync(dropInDir)
25 .filter(d =>
26 (d.isFile() || d.isSymbolicLink()) &&
27 d.name.endsWith('.json') &&
28 !d.name.startsWith('.')
29 )
30 // 按字母排序——后面的文件优先级更高
31 // ...
32 }
33}

Managed settings 支持两种形式:

  1. 单文件managed-settings.json,作为基础
  2. Drop-in 目录managed-settings.d/*.json,按字母排序合并

这个设计借鉴了 systemd 的 drop-in convention:不同团队可以独立部署策略片段(如 10-otel.json20-security.json),无需协调编辑同一个文件。

MDM (Mobile Device Management) 集成

TypeScript
1// 从 settings.ts 行 36-37 可以看到 MDM 导入
2import { getHkcuSettings, getMdmSettings } from './mdm/settings.js'

Claude Code 还支持操作系统级别的 MDM 配置分发:

  • macOS — 通过 MDM profile 分发到 /Library/Managed Preferences/
  • Windows — 通过 HKCU 注册表键

这些都归入 policySettings 来源,与文件 managed settings 合并。

多来源合并

配置合并使用 lodash 的 mergeWith,配合自定义合并策略:

...

合并策略的关键区分:

  • 权限规则数组 (allow, deny, ask) — 拼接。项目的 allow 规则添加到用户的 allow 规则之后,而非替换。
  • 其他数组替换。如 additionalDirectories,后面的来源完全覆盖前面的。
  • 对象深度合并。嵌套字段逐个覆盖。

设置缓存

TypeScript
1// 从 settings.ts 行 40-46 可以看到缓存导入
2import {
3 getCachedParsedFile,
4 getCachedSettingsForSource,
5 getSessionSettingsCache,
6 resetSettingsCache,
7 setCachedParsedFile,
8 setCachedSettingsForSource,
9 setSessionSettingsCache,
10} from './settingsCache.js'

配置读取使用多级缓存:

  1. 文件解析缓存 — 同一个文件路径只解析一次
  2. 来源缓存 — 每个 source 的设置只计算一次
  3. 会话缓存 — 合并后的最终结果只计算一次

当任何来源的文件变更时,缓存被选择性地失效并重建。

设置变更检测

settings.ts 行 27 可以看到导入:

TypeScript
1import { settingsChangeDetector } from '../../utils/settings/changeDetector.js'

changeDetector 使用文件系统监听器(如 fs.watch)检测设置文件的变更。当检测到变更时:

  1. 重新解析被修改的文件
  2. 失效受影响的缓存层
  3. 触发 useSettingsChange 回调
  4. 通过 store.setState 更新 AppState
  5. onChangeAppState 处理副作用(如清除认证缓存)

版本化迁移

src/migrations/ 目录包含 11 个迁移函数,处理配置格式的历史演变。

迁移列表

流程
migrateAutoUpdatesToSettings.ts — 自动更新偏好迁移到 settings.json
migrateBypassPermissionsAcceptedToSettings.ts — 权限绕过设置迁移
migrateEnableAllProjectMcpServersToSettings.ts — MCP 服务器启用设置迁移
migrateFennecToOpus.ts — Fennec 模型别名迁移到 Opus
migrateLegacyOpusToCurrent.ts — 旧 Opus 名称迁移
migrateOpusToOpus1m.ts — Opus Opus[1m] 迁移
migrateReplBridgeEnabledToRemoteControlAtStartup.ts — Bridge 设置迁移
migrateSonnet1mToSonnet45.ts — Sonnet 1m Sonnet 4.5 迁移
migrateSonnet45ToSonnet46.ts — Sonnet 4.5 Sonnet 4.6 迁移
resetAutoModeOptInForDefaultOffer.ts — 自动模式 opt-in 重置
resetProToOpusDefault.ts — Pro 用户默认模型重置

迁移示例:自动更新

TypeScript
1// src/migrations/migrateAutoUpdatesToSettings.ts 行 13-61
2export function migrateAutoUpdatesToSettings(): void {
3 const globalConfig = getGlobalConfig()
4
5 // 只迁移用户明确关闭自动更新的情况
6 if (
7 globalConfig.autoUpdates !== false ||
8 globalConfig.autoUpdatesProtectedForNative === true
9 ) {
10 return
11 }
12
13 try {
14 const userSettings = getSettingsForSource('userSettings') || {}
15
16 // 迁移到 env 变量
17 updateSettingsForSource('userSettings', {
18 ...userSettings,
19 env: {
20 ...userSettings.env,
21 DISABLE_AUTOUPDATER: '1',
22 },
23 })
24
25 logEvent('tengu_migrate_autoupdates_to_settings', {
26 was_user_preference: true,
27 already_had_env_var: !!userSettings.env?.DISABLE_AUTOUPDATER,
28 })
29
30 // 立即生效
31 process.env.DISABLE_AUTOUPDATER = '1'
32
33 // 从旧配置中移除
34 saveGlobalConfig(current => {
35 const { autoUpdates: _, autoUpdatesProtectedForNative: __, ...rest } = current
36 return rest
37 })
38 } catch (error) {
39 logError(new Error(`Failed to migrate auto-updates: ${error}`))
40 }
41}

迁移的关键特征:

  1. 幂等 — 多次运行不会产生副作用
  2. 条件执行 — 只在检测到旧格式时触发
  3. 原子性 — 先写新配置,再删旧配置
  4. 可观测 — 通过 logEvent 记录迁移事件

迁移示例:模型别名

TypeScript
1// src/migrations/migrateFennecToOpus.ts 行 18-45
2export function migrateFennecToOpus(): void {
3 // 仅 ant 用户
4 if (process.env.USER_TYPE !== 'ant') return
5
6 const settings = getSettingsForSource('userSettings')
7 const model = settings?.model
8
9 if (typeof model === 'string') {
10 if (model.startsWith('fennec-latest[1m]')) {
11 updateSettingsForSource('userSettings', { model: 'opus[1m]' })
12 } else if (model.startsWith('fennec-latest')) {
13 updateSettingsForSource('userSettings', { model: 'opus' })
14 } else if (
15 model.startsWith('fennec-fast-latest') ||
16 model.startsWith('opus-4-5-fast')
17 ) {
18 updateSettingsForSource('userSettings', {
19 model: 'opus[1m]',
20 fastMode: true,
21 })
22 }
23 }
24}

这个迁移展示了两个重要决策:

  1. 只迁移 userSettings — 不触碰 project/local/policy settings。源码注释解释了原因:"读取 merged settings 会导致无限重运行 + 静默全局提升"。
  2. 模型重映射fennec-fast-latest 映射到 opus[1m] + fastMode: true,保持用户的性能偏好。

迁移执行时机

迁移在 main.tsxpreAction 阶段执行:

TypeScript
1// main.tsx 中的 profileCheckpoint 显示顺序
2profileCheckpoint('preAction_after_mdm') // 行 915
3profileCheckpoint('preAction_after_init') // 行 917
4profileCheckpoint('preAction_after_sinks') // 行 935
5profileCheckpoint('preAction_after_migrations') // 行 951 ← 迁移在这里完成
6profileCheckpoint('preAction_after_remote_settings') // 行 959

迁移在 sink 初始化之后、远程设置加载之前执行——这意味着迁移可以使用遥测(记录迁移事件),但不依赖远程设置。

远程管理配置

src/services/remoteManagedSettings/index.ts 实现了从 API 拉取企业级管理配置。

TypeScript
1// src/services/remoteManagedSettings/index.ts 行 1-13
2/**
3 * Remote Managed Settings Service
4 *
5 * Manages fetching, caching, and validation of remote-managed settings
6 * for enterprise customers. Uses checksum-based validation to minimize
7 * network traffic and provides graceful degradation on failures.
8 *
9 * Eligibility:
10 * - Console users (API key): All eligible
11 * - OAuth users (Claude.ai): Only Enterprise/C4E and Team subscribers
12 * - API fails open (non-blocking)
13 * - API returns empty settings for users without managed settings
14 */

Checksum 缓存

远程配置使用 checksum 机制减少网络流量。类似 HTTP ETag,但基于内容的 SHA256 哈希:

  1. 首次拉取 → 存储配置 + 计算 checksum
  2. 后续拉取 → 带上 checksum,服务端比对
  3. 如果未变 → 304 Not Modified,使用缓存
  4. 如果变了 → 200 + 新配置

安全检查

TypeScript
1// src/services/remoteManagedSettings/index.ts 行 38-39
2import {
3 checkManagedSettingsSecurity,
4 handleSecurityCheckResult,
5} from './securityCheck.jsx'

远程配置在应用前需要通过安全检查。securityCheck.tsx 确保远程配置不会引入危险操作——例如,远程配置不应该能设置任意的 env 变量或修改权限的 deny 规则。

后台轮询

TypeScript
1// src/services/remoteManagedSettings/index.ts 行 54-55
2const POLLING_INTERVAL_MS = 60 * 60 * 1000 // 1 hour

远程配置每小时轮询一次。加载过程是非阻塞的——如果 API 不可达,继续使用缓存或无远程配置运行。

策略限制 (Policy Limits)

src/services/policyLimits/index.ts 是另一个企业配置层——组织级别的功能限制。

TypeScript
1// src/services/policyLimits/index.ts 行 510-526
2export function isPolicyAllowed(policy: string): boolean {
3 const restrictions = getRestrictionsFromCache()
4 if (!restrictions) {
5 // HIPAA 模式下的安全降级
6 if (isEssentialTrafficOnly() && ESSENTIAL_TRAFFIC_DENY_ON_MISS.has(policy)) {
7 return false
8 }
9 return true // fail open
10 }
11 const restriction = restrictions[policy]
12 if (!restriction) return true // 未知策略 = 允许
13 return restriction.allowed
14}

策略限制的设计原则:

  1. Fail open — 默认允许。网络故障不会阻止用户使用 CLI。
  2. HIPAA 例外 — 对于 essential-traffic-only 模式,特定策略(如 allow_product_feedback)在缓存不可用时默认拒绝。
TypeScript
1// src/services/policyLimits/index.ts 行 502
2const ESSENTIAL_TRAFFIC_DENY_ON_MISS = new Set(['allow_product_feedback'])

缓存架构

...

三级缓存:

  1. 会话缓存 — 内存中的 sessionCache,最快
  2. 磁盘缓存~/.claude/policy-limits.json,进程重启后可恢复
  3. 网络获取 — API 请求,带重试和指数退避

ETag 缓存

TypeScript
1// src/services/policyLimits/index.ts 行 132-159
2function computeChecksum(
3 restrictions: PolicyLimitsResponse['restrictions'],
4): string {
5 const sorted = sortKeysDeep(restrictions)
6 const normalized = jsonStringify(sorted)
7 const hash = createHash('sha256').update(normalized).digest('hex')
8 return `sha256:${hash}`
9}

Checksum 基于规范化的 JSON 计算——先递归排序所有键,再序列化,再哈希。这确保即使服务端返回的字段顺序不同,只要内容相同,checksum 就相同。

认证支持

TypeScript
1// src/services/policyLimits/index.ts 行 227-262
2function getAuthHeaders(): { headers: Record<string, string>; error?: string } {
3 // 先尝试 API key(Console 用户)
4 try {
5 const { key: apiKey } = getAnthropicApiKeyWithSource({
6 skipRetrievingKeyFromApiKeyHelper: true,
7 })
8 if (apiKey) {
9 return { headers: { 'x-api-key': apiKey } }
10 }
11 } catch { /* 继续尝试 OAuth */ }
12
13 // 回退到 OAuth tokens(Claude.ai 用户)
14 const oauthTokens = getClaudeAIOAuthTokens()
15 if (oauthTokens?.accessToken) {
16 return {
17 headers: {
18 Authorization: `Bearer ${oauthTokens.accessToken}`,
19 'anthropic-beta': OAUTH_BETA_HEADER,
20 },
21 }
22 }
23
24 return { headers: {}, error: 'No authentication available' }
25}

策略限制 API 支持两种认证方式:

  1. API key — Console 用户直接使用 x-api-key
  2. OAuth — Claude.ai 用户使用 Bearer token

skipRetrievingKeyFromApiKeyHelper: true 避免触发 API key helper 的执行——在策略限制检查这样的高频路径上,不应该启动外部进程获取密钥。

初始化加载 Promise

TypeScript
1// src/services/policyLimits/index.ts 行 94-114
2export function initializePolicyLimitsLoadingPromise(): void {
3 if (loadingCompletePromise) return
4
5 if (isPolicyLimitsEligible()) {
6 loadingCompletePromise = new Promise(resolve => {
7 loadingCompleteResolve = resolve
8
9 // 防死锁超时
10 setTimeout(() => {
11 if (loadingCompleteResolve) {
12 loadingCompleteResolve()
13 loadingCompleteResolve = null
14 }
15 }, LOADING_PROMISE_TIMEOUT_MS) // 30 秒
16 })
17 }
18}

远程管理配置和策略限制都使用了相同的 Promise 模式:

  1. 在初始化早期创建 Promise
  2. 其他系统可以 await waitForPolicyLimitsToLoad() 等待加载完成
  3. 30 秒超时防止死锁——如果 loadPolicyLimits() 从未被调用(如在 Agent SDK 测试中),Promise 自动解决

配置验证与错误处理

配置文件解析不是简单的 JSON.parse。每个文件都经过完整的 Zod Schema 验证:

  1. JSON 解析 — 文件可能是无效 JSON
  2. Schema 验证 — 字段类型、格式、范围检查
  3. 权限规则过滤 — 无效的权限规则被过滤而非拒绝整个文件
  4. 错误收集 — 所有验证错误被收集,不中断加载

这种设计的核心原则是韧性——一个格式错误的配置文件不应该阻止 CLI 启动。无效的规则被跳过,有效的规则继续生效。

来源显示名称

TypeScript
1// src/utils/settings/constants.ts 行 26-93
2export function getSettingSourceName(source: SettingSource): string {
3 switch (source) {
4 case 'userSettings': return 'user'
5 case 'projectSettings': return 'project'
6 case 'localSettings': return 'project, gitignored'
7 case 'flagSettings': return 'cli flag'
8 case 'policySettings': return 'managed'
9 }
10}
11
12export function getSourceDisplayName(
13 source: SettingSource | 'plugin' | 'built-in',
14): string {
15 switch (source) {
16 case 'userSettings': return 'User'
17 case 'projectSettings': return 'Project'
18 case 'localSettings': return 'Local'
19 case 'flagSettings': return 'Flag'
20 case 'policySettings': return 'Managed'
21 case 'plugin': return 'Plugin'
22 case 'built-in': return 'Built-in'
23 }
24}

提供多种格式的显示名称——短名(UI 标签用)、描述名(内联文本用)、大写名(上下文/技能 UI 用)。这确保在不同的 UI 场景中,配置来源都能被清晰地识别。

总结

Claude Code 的配置系统是一个工程上的精品:

  • 5 来源优先级合并 — user < project < local < flag < policy,策略不可绕过
  • Zod Schema 验证 — 编译期 feature flag 控制 Schema 形状,lazySchema 惰性构建
  • Drop-in 目录 — 借鉴 systemd convention,多团队独立部署策略片段
  • 11 个版本迁移 — 幂等、条件执行、原子更新、可观测
  • 远程管理配置 — checksum 缓存、ETag 优化、fail-open 策略
  • 策略限制 — 三级缓存、HIPAA 安全降级、双认证支持
  • 配置热更新 — 文件监听 → 缓存失效 → Store 更新 → 副作用执行