“Codex 读取 config.toml”这句话省略了真正困难的部分。一个实际 Thread 可能同时受到主机配置、
企业云配置、用户配置、Profile、项目配置、CLI 参数、UI 临时设置和管理员要求影响。Runtime 最终
看到的不是某一个文件,而是一份保留来源、信任和约束信息的有效配置。
如果只实现 dict.update(),至少会出现四类错误:不可信仓库改变 API 地址;CLI 覆盖被 Profile
吞掉;相对路径按错误目录解释;管理员 allow-list 被普通高优先级值绕开。
配置系统实际产生两个结果
配置加载不是只产生 Config。它先产生两类相互关联但不能混合的数据:
- 普通配置层:回答“当前值是什么、来自哪里”;
- Requirements:回答“这个值即使被选择了,是否仍被管理员允许”。
普通层可以用覆盖关系合并;Requirements 可能是 allow-list、必选值或复合安全约束,不能简化成 “优先级最高的一份 TOML”。
普通层严格按低到高排列
当前层级可以压缩成下面这张表:
| 次序 | 来源 | 能解决的问题 | 主要限制 |
|---|---|---|---|
| 0 | MDM 普通配置 | 设备级基础值 | 平台/来源限定 |
| 10 | System | 主机公共默认 | 需要系统位置权限 |
| 15 | Enterprise cloud | 组织分发配置 | 由企业 bundle 提供 |
| 20 | User base | 个人默认 | 可被更高层覆盖 |
| 21 | Profile v2 | 选定工作场景的增量 | 不得与同名 legacy profile 混用 |
| 25 | Project | 仓库局部设置 | 必须经过信任与敏感键过滤 |
| 30 | Session flags | CLI、UI、Thread 临时设置 | 只在当前运行范围生效 |
| 40/50 | legacy managed | 兼容旧受管配置 | 位于 ordinary stack 顶部 |
层数组始终低优先级在前。合并器按这个顺序遍历,后层覆盖前层;诊断器同时记录最终字段来源。
“受管配置永远最高”不是一个足够准确的总结。兼容的 legacy managed config 的确在 ordinary stack 顶部;现代 requirements 则不靠覆盖优先级生效,而是在普通值解析后执行约束。
Loader 不是先把所有文件读进来再 merge
项目配置的发现本身依赖较低层配置。例如项目根标记可以来自 system/user,也可以被 CLI 覆盖。
因此 Loader 先合并已经读取的层和 CLI 覆盖,用这个临时视图解析项目根与信任,再去加载 cwd、
父目录树和 Git 根中的 .codex/config.toml。
async def load_layer_stack(request: ConfigLoadRequest) -> ConfigLayerStack:
requirements = await compose_requirements(
system=read_system_requirements(request),
cloud=read_cloud_requirements(request),
legacy=read_legacy_requirements(request),
admin=read_admin_requirements(request),
)
layers: list[ConfigLayer] = []
layers.append(await read_system_config(request))
layers.extend(await read_enterprise_cloud_config(request))
layers.append(await read_base_user_config(request))
if request.profile_v2 is not None:
reject_same_named_legacy_profile(layers, request.profile_v2)
layers.append(await read_profile_overlay(request.profile_v2))
preliminary = merge_enabled(layers)
preliminary = merge(preliminary, request.cli_override_layer)
project_context = await resolve_project_root_and_trust(preliminary, request.cwd)
layers.extend(await read_project_layers(project_context))
if request.cli_override_layer:
layers.append(request.cli_override_layer)
for thread_layer in await request.thread_config_loader.load(request.cwd):
insert_by_precedence(layers, thread_layer)
layers.extend(await read_legacy_managed_config(request))
validate_each_enabled_layer_before_merge(layers)
return ConfigLayerStack(layers=layers, requirements=requirements)
临时视图把 CLI 覆盖计算进去,却不会提前把 CLI 层固定到最终数组错误位置。最终仍以层来源权重和 插入顺序建立可审计 Stack。
Profile v2 是增量层,不是另一份完整用户配置
选择 Profile v2 时,基础用户配置仍然保留,Profile 文件只需要写变化项。比如 base 设置审批 策略,Profile 只改模型,最终两者都会存在:
# $CODEX_HOME/config.toml
approval_policy = "on-request"
model = "base-model"
# $CODEX_HOME/work.config.toml
model = "work-model"
有效结果是 approval_policy=on-request、model=work-model。Profile 层同时成为 profile-aware
编辑操作的写入目标,避免 UI 把修改错误写回 base 文件。
旧系统曾允许 base 文件中的 profile = "work" 和 [profiles.work]。若 Profile v2 又选择同名
Profile,系统不会猜测谁覆盖谁,而是直接返回配置错误。这种“拒绝歧义”比建立第三套隐式优先级
更安全。
def add_profile_v2_layer(
base: ConfigLayer,
selected: ProfileName,
overlay: ConfigLayer,
) -> list[ConfigLayer]:
if base.legacy_selected_profile == selected:
raise ConfigError("profile-v2 conflicts with legacy profile selector")
if selected in base.legacy_profile_tables:
raise ConfigError("profile-v2 conflicts with legacy profile table")
return [base, overlay]
Project 配置不是普通用户配置
仓库内容可能来自不可信分支或刚下载的代码。它可以建议与项目有关的设置,但不应该决定用户 凭据被发送到哪个地址,也不应该配置主机通知命令。Loader 因此有两道边界:
- 未信任项目层可以保留在 Stack 中用于诊断,但带
disabled_reason,不进入 effective config; - 项目层对 Provider、Base URL、通知、Profile、OTel 等敏感键设 denylist。
保留 disabled 层很重要。若直接丢弃,UI 只能看到“配置没有生效”,无法解释是文件未发现、项目 未信任,还是键不允许。
相对路径必须在每层读取时解析
System、User、Profile 和 Project 文件可能处于完全不同目录。若先 merge 再统一按 cwd 展开
./scripts/hook.sh,两个不同来源的同一字符串会被解释成同一个文件。
当前设计在加载每一层时,以该文件的父目录为 base 解析相对路径。进入 Stack 后,路径已经是绝对 语义,最终反序列化不再猜来源目录。
同优先级 SessionFlags 仍然有顺序
CLI/UI override 和 ThreadConfigLoader 产出的运行时层都可能标记为 SessionFlags。它们权重相同, 最终覆盖关系由稳定的插入顺序决定。当前 Loader 先加入 CLI 层,再按优先级插入 Thread 层;同权重 后插入项位于更高位置,因此 Thread-specific layer 可以覆盖当前 Session flag。
这不是让任意项目内容绕过 CLI。ThreadConfigLoader 是 Runtime 提供的受控来源,不等同于仓库
.codex/config.toml。
为什么要在 merge 前逐层校验
假设低层用数组表示 shell 环境过滤器,高层用表格表示同一字段。直接 merge 可能留下一个看似 合法的最终值,却掩盖低层本身采用了禁止混用的表示。当前实现先验证每一个启用层,再执行合并。
def build_effective_toml(stack: ConfigLayerStack) -> EffectiveToml:
for layer in stack.layers_low_to_high(include_disabled=False):
validate_layer_representation(layer)
merged: TomlValue = empty_table()
origins: dict[KeyPath, LayerMetadata] = {}
for layer in stack.layers_low_to_high(include_disabled=False):
deep_merge(merged, layer.config)
update_field_origins(origins, layer)
return EffectiveToml(value=merged, origins=origins)
代价是高层即使完全覆盖了低层错误,启动仍然失败。但这能避免机器 A 与机器 B 因合并细节不同而 接受不同配置,也让错误能指向真正有问题的文件。
Requirements 在领域解析时收窄结果
Requirements 可能要求只允许一组 Permission Profile、限制 MCP Server、强制安全策略,或禁止某个
Feature。它们保存在 Stack 中,却明确不参与 effective_config()。
领域 Config 构造大致是:
async def build_domain_config(builder: ConfigBuilder) -> Config:
cwd = resolve_absolute_cwd(builder.cwd_override)
stack = await load_layer_stack(builder.to_load_request(cwd))
effective = build_effective_toml(stack)
raw = deserialize_config_toml(effective.value)
config = await load_domain_fields(
raw=raw,
harness_overrides=builder.harness_overrides,
source_stack=stack,
)
apply_managed_requirements(config, stack.requirements)
return config
若把 Requirements 当成最后一层 TOML,无法表达“值必须属于集合”“某个子结构只能收窄不能放宽” 等规则,也难以保留独立的 RequirementSource 诊断。
失败矩阵
| 条件 | 发现阶段 | 结果 |
|---|---|---|
| Profile v2 与同名 legacy profile 并存 | User/Profile 装配 | ConfigError,拒绝启动 |
| 项目未信任 | Project discovery | 层保留但 disabled,不参与合并 |
| Project 写敏感 Provider/Base URL | Project sanitization | 剔除或拒绝,并产生诊断 |
| 单层 TOML 类型错误 | 分层反序列化/诊断 | 指向具体来源文件 |
| 单层 shell policy 表示非法 | merge 前校验 | 即使高层覆盖也失败 |
| requirements 与用户选择冲突 | 领域 Config 约束 | 拒绝或选择允许的受管默认 |
| 相对路径无法按来源解析 | 层加载 | 当前层失败,不进入 Stack |
| Config lockfile 被配置 | ConfigBuilder | 改走锁文件配置路径,并保留 requirements |
测试应保护值、来源和禁用原因
只断言最终 model == x 不够。配置测试还应断言层次顺序、active user layer、字段来源和 disabled
reason:
async def test_profile_is_overlay_and_write_target() -> None:
stack = await load(base_user={"model": "base", "approval": "ask"},
profile={"model": "work"})
assert stack.effective["model"] == "work"
assert stack.effective["approval"] == "ask"
assert stack.origins["model"].profile == "work"
assert stack.active_user_layer.profile == "work"
async def test_untrusted_project_is_visible_but_inactive() -> None:
stack = await load(project={"model": "repo-model"}, trusted=False)
project = stack.find_layer("project")
assert project.disabled_reason is not None
assert stack.effective["model"] != "repo-model"
当前生产测试已经覆盖 Profile overlay、Thread session 覆盖、managed 顶层、逐层校验和受管 Permission Profile。后续新增配置来源时,应同时扩展 precedence、来源格式化、ordering 校验和 API 投影测试。
小结
Codex 的有效配置是“有来源的普通值 + 独立的受管约束”,不是一张来自最后一个文件的 TOML 表。 Profile、项目信任、SessionFlags 和相对路径解析都依赖保留层边界。
配置完成后,features.* 只是普通值中的一部分。它们还要经过默认值、兼容键、结构化配置和依赖
规范化,才会决定 Runtime 实际启用哪些分支。2.3 将进入这一步。
评论
登录后即可评论