青雲的博客
拆开 Codex 第一部:先建立系统地图 第 03 章

Codex 启动时,哪一份配置说了算

从 LoaderOverrides、ordinary layers、SessionFlags 到 typed ConfigOverrides,拆开 Codex 配置加载、合并、最终覆盖与来源追踪。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

“Codex 最后用了哪份配置”这个问题,表面上像是在几个 config.toml 里找最高优先级的文件。源码实际把它拆成了三个问题:哪些输入会成为配置层候选,候选值按什么规则合并,合并完成后是否还有 typed runtime override 改写最终 Config。如果把三者压成一张“优先级表”,-c model=...--model ...-C ... 看起来都会像最高层 CLI 配置,但它们进入系统的位置并不相同。

本章把进入 ConfigLayerStack、参与 TOML merge 的层简称 ordinary layers。这里只处理 ordinary layers、typed ConfigOverrides 和 ordinary provenance。第 2 章列出的 exec、app-server、mcp-server 等入口不会在这里逐条重走;下面用常规 TUI 展开共享合并规则。Requirements 是另一条约束通道;它不应该被偷偷塞进普通配置的 merge 结论里。

先把 CLI 输入的落点分开

交互 TUI 的常规启动路径会把三类值分别交给 ConfigBuilder:profile 选择成为 LoaderOverrides-c 解析结果成为 cli_overrides--model、approval、sandbox、-C 等强类型参数组成 ConfigOverrides 后进入 harness_overrides。表的前三行对应这三类输入;第四行单列第三类 ConfigOverridescwd 特例。这个分流比“都来自命令行”更重要。

输入进入点对 ordinary layer / 最终配置的影响
LoaderOverridesuser_config_pathuser_config_profile 是加载输入决定 loader 读取哪个 profile 文件以及如何标记 user layer;自身不是字段覆盖层
-c key=valueCliConfigOverrides 解析 TOML path/value,再送入 SessionFlags形成 precedence 30 的 ordinary layer,进入 effective_config() 和 ordinary origins
ConfigOverrides通过 harness_overrides 进入 core属于 typed runtime override,在 ordinary TOML merge 之后应用;对 ordinary origins 不可见
-C / --cdCLI 字段写入 ConfigOverrides.cwd先影响 project discovery 的起点和相对路径解析,也进入最终运行配置;它本身不形成 layer

CliConfigOverrides 会保留每个 -c key=value 的原始字符串,按第一个 = 拆分,并尝试把右侧解析为 TOML;解析失败时才退为字符串。于是 -c 不是把一段文本追加到 config.toml,而是在 loader 里构造一棵可以参与普通 merge 的 TOML overlay。

typed ConfigOverrides 在 core 中被完整解构,model、approval、sandbox、cwd 等字段继续走强类型逻辑。它不是 ConfigLayerEntry,因此不要期待 origins() 为这些最终覆盖值返回一个 SessionFlags 来源。

固定实验:profile 是稀疏 overlay

先按第一部导读创建 disposable archive,再运行仓库已有的测试,不手写一个看起来合理的配置样例:

: "${ARCHIVE_CODEX_RS:?先执行第一部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-core selected_user_config_file_layers_over_base_user_config

固定源码的 AGENTS.md 要求通过 just test 运行 Rust 测试;recipe 会调用 nextest 并保留仓库自己的运行参数。这个 test 名位于 module path 之下,按上面的 substring filter 运行即可。

测试建立 base user config 和选中的 work.config.toml,然后检查两层合并结果:

字段BaseProfileEffective
modelgpt-maingpt-workgpt-work
approval_policyon-request省略on-request

它只证明当前固定版本中,选中的 user config file 作为第二个 user layer 覆盖 base,而且 profile 省略的字段会保留低层值。它没有证明 project、CLI、legacy managed 或 typed ConfigOverrides 的相对关系;这些要回到 stack 构造和最终 Config 解析路径分别判断。

Ordinary layers 的真实顺序

ConfigLayerSource::precedence() 给出的数字才是 ordinary stack 的低到高顺序。下面的源码片段讲的是 loader 如何组装 layers: Vec<ConfigLayerEntry> 并交给 ConfigLayerStack,不是文件 I/O 顺序;cloud、legacy、thread 数据可能已在更早处读入,这里只讨论 stack 构造和 append / insertion 顺序。当前标准 loader 会构造下面这些 active ordinary layers:

Layer sourcePrecedence这一层从哪里来
System10host-wide system config.toml
EnterpriseManaged15enterprise cloud bundle;在 system 之后进入 stack
User base20$CODEX_HOME/config.toml
User profile21选中的独立 profile config file;只需写差异字段
Project25从 project root 到 cwd 沿途的 .codex/config.toml;越接近 cwd 越高
SessionFlags (-c / UI)30loader 构造的 CLI 或 UI ordinary overlay
SessionFlags (thread)30thread loader 提供;同一 precedence 时后插入,因此覆盖先前的 session flags
LegacyManagedConfigTomlFromFile40legacy managed_config.toml 文件
LegacyManagedConfigTomlFromMdm50legacy 移动设备管理(Mobile Device Management,MDM)配置

ConfigLayerSource 里还保留 precedence 0 的 Mdm variant,但当前这条标准 loader 路径没有把它作为上表中的 active row 插入。把 enum 的全部可能值直接抄成当前加载顺序,会多出一层实际没有在这里构造的配置。

构造 layers Vec 时,loader 先准备 -c overlay,再 push system layer、extend 已得到的 cloud layers,随后放入 base user 和 profile user layer。profile 文件与 base 文件不同时才会加入第二层。若 base config 里仍用同名 legacy profile = "work"[profiles.work],同时又选择新的独立 work.config.toml,loader 会直接拒绝,避免同一个 profile 名同时代表两套机制。

在同一个 layers Vec 的构造阶段,project discovery 使用最终 cwd,从 project root 向 cwd 扩展 project layers。随后 loader 才 push CLI/UI SessionFlags,再按 precedence 插入 thread layers。两个 SessionFlags 都是 30;插入规则寻找第一个严格更高的层,因此同 precedence 的 thread layer 会落在已有 session layer 之后。

接着继续构造 layers Vec:push legacy managed file 和 legacy MDM config,最后把已经按 precedence 排好的这份 Vec 交给 ConfigLayerStack。这解释了为什么 ordinary value 可以被 legacy managed layer 盖住,但还不能用来推断 Requirements 或 typed runtime override 的最终结果。

同为 Project 的多层还有内部顺序。load_project_layers 先枚举 cwd 到 root 的 ancestors,再 reverse,因此产物从 root 最近处开始、到 cwd 最近处结束;越靠近 cwd 的 .codex/config.toml 越晚合并。

Merge 规则取决于 TOML 类型

有了顺序,还要知道“覆盖”究竟替换多大范围。merge_toml_values 只有在 base 与 overlay 当前节点都是 table 时递归;其余情况都把当前 base 节点替换为 overlay 节点。

Base / overlay 类型合并行为结果边界
table + table按 key 递归合并overlay 只覆盖它提供的叶子;低层未出现于 overlay 的 sibling 保留
array整体替换不逐项追加,也不按 index 合并
scalar 或类型变化整体替换当前路径直接取 overlay value

这就是实验里 profile 省略 approval_policy 后,base 值仍然存在的原因:顶层两边都是 table,merge 只递归到 profile 实际给出的 model。但如果 profile 给出一个 array,低层 array 不会留下未提及的成员。

effective_config()origins() 的边界

配置排查常在这里走偏:effective_config() 叫 effective,origins() 又能返回每个 path 的 metadata,看起来已经足以解释最终运行值。源码给出的范围更窄,它们描述 ordinary layer stack,不是完整 Config 构造过程。

API能回答什么明确不能回答什么
effective_config()按低到高合并 enabled ordinary layers跳过 disabled layer,并且不应用 Requirements;它也还没叠加 typed runtime override
origins()为 key-alias-normalized leaf path 记录最后写入它的 ordinary layer metadataordinary-only:不含 typed ConfigOverrides可能 stale:不能当作最终树的完整镜像;先查 effective:先在 effective_config() 确认 exact leaf path,再查 origins()

查询规则。 先在 effective_config() 中确认 exact leaf path,再用同一个 key-alias-normalized leaf path 查询 origins()。这里的 normalized 只指 key aliases;origins() 不镜像 merge 中额外的 value/domain-key normalization。两者都以 include_disabled = falseLowestPrecedenceFirst 遍历 ordinary layers。

为什么会 stale。 origins() 按层递归记录,并不按最终 merged shape 清理旧路径。例如,低层父 table 含 section.mode,高层 scalar 把 section 整体替换为 "off"effective_config() 已没有 section.modeorigins() 仍可能保留这个 leaf path。短数组也一样:高层 array 比低层短时,旧的 index path 可能成为 stale path。

这里的 version 是整层 SHA-256,不是字段自己的 hash。version_for_toml 会先把传入的 TOML 转为 canonical JSON,再对序列化后的整棵 value 做 SHA-256;record_origins 只是把这份 layer metadata 复制到各个 key-alias-normalized leaf。于是多个字段可以共享同一个 version。这是源码推导的 layer metadata,不提供行号、mtime、字段 hash、default 或 derived value 的来源。

ordinary TOML merge 被反序列化后,typed override 才参与最终字段选择。以 model 为例,core 使用 model.or(cfg.model):typed model 存在就取它,否则回退到 ordinary config 的 cfg.model。因此“origins() 说 model 来自 user config”与“本次运行实际使用 CLI --model”可以同时成立,前者没有承诺覆盖后者。

图只画 ordinary merge 后 typed fields 的最终覆盖主路径。ConfigOverrides.cwd 是 project-layer loading 前的例外:它先作为 project discovery input,影响 layer construction;不要把所有 typed overrides 看成同一时点。

flowchart TB
    accTitle: Codex configuration resolution boundaries
    accDescr: 图只表示 ordinary merge 后 typed ConfigOverrides fields 的最终覆盖主路径;ConfigOverrides.cwd 是例外,在 project-layer loading 前作为 project discovery input 进入 layer construction,不与其他 typed fields 同时应用。LoaderOverrides 选择加载输入,-c 形成 SessionFlags ordinary layer;ordinary stack 生成 effective config 和 origins,effective config 反序列化为 ConfigToml。
    LO["LoaderOverrides: path / profile"]
    LOAD["ordinary file loading"]
    CLI["-c key=value"]
    SF["SessionFlags ordinary layer"]
    STACK["ordinary layer stack"]
    CWD["ConfigOverrides.cwd: before-load project discovery input"]
    DISC["project discovery before project-layer loading"]
    EFF["effective_config()"]
    CT["typed ConfigToml"]
    FINAL["final Config"]
    TO["typed ConfigOverrides fields: post-merge"]
    ORIG["origins(): ordinary provenance"]
    LO --> LOAD
    CLI --> SF
    CWD --> DISC
    DISC --> STACK
    LOAD --> STACK
    SF --> STACK
    STACK --> EFF
    EFF --> CT
    CT --> FINAL
    TO --> FINAL
    STACK --> ORIG

这张图刻意没有 TO --> ORIG:typed override 不属于 ordinary stack。它也没有让 Requirements 指向 effective_config(),因为 ordinary merge 不负责应用那条约束通道。

下一章:从候选值进入 Requirements

到这里,我们能解释一个 ordinary field 为什么来自某个 layer,也能解释 typed override 为什么可能再次改变最终值。但企业约束还会提出另一类问题:某个候选值是否被允许,冲突时系统怎样拒绝或收窄它。那不是再加一个高 precedence TOML layer 就能完整表达的语义。

第 4 章《配置能覆盖,Requirements 不能绕过》会单独追踪 Requirements 的来源、组合与执行边界。本章停在交接点:先算 ordinary candidate,再进入约束判断;不要用本章的 layer table 预判下一章的结果。