# 配置系统 · 分层加载与托管要求

> Codex 的配置由两摞“层”组成：config 层按优先级逐键合并出生效值，requirements 层合成管理员的约束，再把取值关进 Constrained 校验器。本篇沿 codex-config 的加载器走一遍来源与优先级、-c 覆盖、profile、项目信任、未知字段告警，以及改写 config.toml 的方式。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/config-system
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 配置系统 · 分层加载与托管要求

第 5 部分讲扩展机制。skills、hooks、插件、MCP 服务器最终都落在 `config.toml` 的某张表里，所以先把配置系统本身拆开：值从哪几层来、谁覆盖谁、管理员的约束怎么压住用户的取值。

## 怎么用

用户配置在 `~/.codex/config.toml`，`--profile work` 再叠一份 `~/.codex/work.config.toml`；仓库里的 `.codex/config.toml` 只在项目受信任时生效；`-c key=value` 单次覆盖，`--enable` / `--disable` 切换功能开关；`/etc/codex/config.toml` 提供默认值，`requirements.toml` 提供管理员的强制约束。TUI 里 `/debug-config` 列出每一层与约束来源，`/warnings` 查看启动警告。写法见手册[配置文件 config.toml](https://daiw.net/manual/codex/configuration)与[配置项速查](https://daiw.net/manual/codex/config-reference)。

## 全景：两摞层，一条流水线

配置代码分在三个 crate：`codex-config`（`codex-rs/config/`）负责读文件、分层、合并、约束与诊断，不认识会话；`codex-core` 的 `config/mod.rs` 把合并结果做成运行时用的 `Config`，这个结构体有 140 多个公开字段（`codex-rs/core/src/config/mod.rs:608`）；`codex-features` 维护功能开关表。入口是 `ConfigBuilder::build`（`codex-rs/core/src/config/mod.rs:1475`）：

```mermaid
flowchart LR
  A[内置默认 · system · 云端<br/>user · profile · 项目 .codex<br/>-c 覆盖 · 旧 managed_config] --> S[ConfigLayerStack<br/>按优先级排序]
  S --> M[effective_config<br/>逐键合并]
  M --> T[ConfigToml]
  B[system requirements.toml<br/>云端 · 旧 managed_config · MDM] --> C[compose_requirements]
  C --> Q[ConfigRequirements<br/>Constrained 校验器]
  T --> F[load_config_with_layer_stack]
  Q --> F
  F --> G[Config]
```

`load_config_layers_state`（`codex-rs/config/src/loader/mod.rs:145`）一次产出这两摞：config 层是“可被覆盖的取值”，合并成一张 TOML 表后反序列化为 `ConfigToml`；requirements 层是“不可越过的约束”，合成 `ConfigRequirements`，在构造 `Config` 时逐项压上去。

## config 层：一个整数决定谁覆盖谁

每一层都带一个 `ConfigLayerSource` 标明出处，优先级就是一个整数：

```rust
    pub fn precedence(&self) -> i16 {
        match self {
            ConfigLayerSource::PackagedDefaults { .. } => -10,
            ConfigLayerSource::Mdm { .. } => 0,
            ConfigLayerSource::System { .. } => 10,
            ConfigLayerSource::EnterpriseManaged { .. } => 15,
            ConfigLayerSource::User { profile, .. } => {
                if profile.is_some() {
                    21
                } else {
                    20
                }
            }
            ConfigLayerSource::Project { .. } => 25,
            ConfigLayerSource::SessionFlags => 30,
            ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
            ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50,
        }
    }
```

（`codex-rs/config/src/config_layer_source.rs:33`）

| 层 | 内容 | 优先级 |
| --- | --- | --- |
| `PackagedDefaults` | 编进二进制的 `codex-rs/config/defaults.toml`，如 `project_root_markers = [".git"]`、`project_doc_max_bytes = 32768` | -10 |
| `System` | `/etc/codex/config.toml`，Windows 为 `%ProgramData%\OpenAI\Codex\config.toml` | 10 |
| `EnterpriseManaged` | 企业工作区从云端下发的配置包 | 15 |
| `User` | `$CODEX_HOME/config.toml`；选了 profile 再叠一层 | 20、21 |
| `Project` | 项目根到 cwd 沿途每个 `.codex/config.toml` | 25 |
| `SessionFlags` | `-c` 覆盖，以及 `ThreadConfigLoader` 提供的线程级配置 | 30 |
| `LegacyManagedConfigTomlFrom*` | 旧式 `managed_config.toml`，来自文件或 macOS MDM | 40、50 |

`ConfigLayerStack::new` 用 `verify_layer_ordering` 确认层按这个整数有序、项目层从根到 cwd 排列（`codex-rs/config/src/state.rs:594`）；`effective_config()` 从低到高依次合并，带 `disabled_reason` 的层跳过。每层还存一个 `version`：把 TOML 转成键已排序的 JSON 再算 SHA-256，写回配置时要用。

<Callout type="info">
  枚举里优先级为 0 的 `Mdm` 变体，v0.158.0 的加载器并不产出：macOS MDM 下发的 `config_toml_base64` 被当作旧式托管配置，以 `LegacyManagedConfigTomlFromMdm`（50）压在最上面。`load_config_layers_state` 的文档注释把 MDM 下发的配置排在 system 之下，与代码不符（同目录的 `README.md` 则与代码一致），以代码为准。
</Callout>

## 合并与 -c 覆盖

合并规则在 `merge_toml_values`（`codex-rs/config/src/merge.rs:57`）：两边都是表就逐键递归，否则上层整个替换下层——数组也不例外，高层写的 `writable_roots` 会整体盖掉低层的，而不是追加。特例只有几处，例如 `features.code_mode` 这类既能写布尔又能写表的结构化开关，合并时在两种形态之间转换。合并之前，各层的相对路径已按该层文件所在目录解析成绝对路径（`resolve_relative_paths_in_config_toml`），所以项目配置里的 `./x` 指向 `.codex/` 下，而不是 cwd。

`-c` 由 `CliConfigOverrides` 按第一个 `=` 拆开，右半边拼成 `_x_ = <值>` 交给 TOML 解析器，再取回哨兵键 `_x_`；解析失败就去掉首尾引号当字符串，所以 `-c model=o3` 不必加引号（`codex-rs/utils/cli/src/config_override.rs:95`）。`build_cli_overrides_layer` 按点号把路径展开成嵌套表，成为 `SessionFlags` 层。子命令前后都能写 `-c`，根级的排在前面，后写的覆盖先写的。`--enable x` 校验 `x` 是已知开关后，翻译成 `features.x=true` 追加进同一个列表（`codex-rs/cli/src/main.rs:959`）。

## profile 与项目层

`--profile work` 让用户层路径指向 `$CODEX_HOME/work.config.toml`，加载器先读基础的 `config.toml`，再把 profile 作为第二个 `User` 层（21）叠上去；名字只允许 ASCII 字母、数字、`_` 与 `-`。基础配置里还留着旧写法 `profile = "work"` 或 `[profiles.work]` 时，加载直接报错，要求迁移。

项目层要先找项目根：从 cwd 往上找 `project_root_markers`，默认 `.git`，而且 `.git` 目录里必须有 `HEAD` 才算。找根之前，加载器先把 system、用户、profile、`-c` 与托管配置合并一遍，只用来读 markers 和 `[projects]` 信任表——哪里算项目、哪个项目受信任，由用户和管理员决定，仓库自己说了不算。然后从根到 cwd，每个存在 `.codex/` 目录的层级生成一个 `Project` 层；没有 `config.toml` 也留一个空层，因为同目录下的 hooks、rules 还要靠它定位（`codex-rs/config/src/loader/mod.rs:1638`）。

信任只决定层的 `disabled_reason`：未受信任的项目层照样加载、照样出现在 `/debug-config` 里，只是合并时跳过；它的 TOML 语法错误也不致命，受信任的层才会报错退出。受信任也不等于全权：

```rust
// Project-local config comes from repository contents, so it should not get to
// choose where a user's credentials are sent or which local commands are run.
// These settings are still supported from user, system, managed, and runtime
// config layers.
const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[
    "openai_base_url",
    "chatgpt_base_url",
    // ...
    "model_provider",
    "model_providers",
    "notify",
    // ...
    "otel",
];
```

（`codex-rs/config/src/loader/mod.rs:84`）

`sanitize_project_config` 删掉这些键并给出启动警告，还剔除 `tui.keymap.chat` 下切换权限模式的两个绑定——注释的说法是，仓库内容不能把一个普通按键变成权限提升（`codex-rs/config/src/loader/mod.rs:1137`）。

## requirements：另一摞层

requirements 的来源同样从低到高收集：system `requirements.toml`、云端配置包里的 requirements、改读成要求的旧 `managed_config.toml`、macOS MDM 的 `requirements_toml_base64`（`codex-rs/config/src/loader/managed_requirements.rs:69`）。`compose_requirements` 对大多数字段沿用 config 的 TOML 合并，只有 `rules.prefix_rules`、`hooks`、`permissions.filesystem.deny_read`、`auto_review.required_on_models` 四类按“高优先级在前”追加或取并集（`codex-rs/config/src/requirements_layers/stack.rs:1`）。合成的 `ConfigRequirementsToml` 有四十来个键（`codex-rs/config/src/config_requirements.rs:1029`），落到 `Config` 上有两种压法：

- **精确值**：`cli_auth_credentials_store`、`chatgpt_base_url`、`sqlite_home`、`log_dir`、`model_provider` 等，由 `apply_to_config` 直接替换用户的值，不一致时记一条警告（`codex-rs/core/src/config/requirements.rs:17`）。
- **允许列表**：`allowed_approval_policies`、`allowed_sandbox_modes`、`allowed_web_search_modes` 等变成 `Constrained<T>`——一个值加一个校验闭包，`set` 时先规范化、再校验（`codex-rs/config/src/constraint.rs:174`）。列表第一项是初始值，也是回退值。

用户配的值不在允许范围内时，并不直接失败：

```rust
    if let Err(err) = constrained_value.set(configured_value) {
        let fallback_value = constrained_value.get().clone();
        // ...
        let message = format!(
            "Configured value for `{field_name}` is disallowed by requirements; falling back to required value {fallback_value:?}. Details: {err}"
        );
        startup_warnings.push(message);

        constrained_value.set(fallback_value).map_err(|fallback_err| {
            // ...
        })?;
        return Ok(true);
    }
```

（`codex-rs/core/src/config/mod.rs:2247`）

回退之后还要检查组合是否自洽：用户要的完全访问被要求强制降成只读、而 `approval_policy` 又是 `never` 时，Codex 拒绝启动并说明原因，而不是悄悄变成“只读又不许问”（`codex-rs/core/src/config/mod.rs:4109`）。约束也不止于启动：`Config.permissions.approval_policy`、`web_search_mode`、`mcp_servers` 等字段本身就是 `Constrained`，会话中途切换同样要过校验；`ManagedFeatures` 把 requirements 的 `[features]` 固定的开关钉住（`codex-rs/core/src/config/managed_features.rs:24`）；app-server 的写配置接口遇到被精确值管住的键，直接返回 `ConfigRequirementReadonly`。

## 未知字段与报错定位

`ConfigToml` 的 serde 派生没有开 `deny_unknown_fields`（它上面的 `#[schemars(deny_unknown_fields)]` 只影响生成的 JSON Schema），拼错的键会被静默丢掉。为了不静默，加载末尾的 `ignored_config_warning` 用 `serde_ignored` 把生效配置再反序列化一遍，收集被忽略的路径，再逐层找出这个键写在哪个文件里；`[features]` 里不在开关表中的键另算未知。警告最多列三条、每条注明来源层，其余只报数量（`codex-rs/config/src/strict_config.rs:205`）。加 `--strict-config` 时，同样的检查在读每个文件时就做，第一处未知字段直接报错。

类型错误走另一条路：合并后的表反序列化失败，`first_layer_config_error` 就逐层单独反序列化，找到第一个出错的文件，用 `toml_edit` 定位行列，渲染成编译器风格的报错（`codex-rs/config/src/diagnostics.rs:170`）。`[tui.keymap]` 是例外：它的结构体开了 `serde(deny_unknown_fields)`，写错动作名是硬错误。

## 功能开关与写回

`codex-features` 用静态表 `FEATURES` 登记全部开关，每项是 `FeatureSpec { id, key, stage, default_enabled }`（`codex-rs/features/src/lib.rs:916`）。v0.158.0 共 151 项：稳定 47、开发中 57、实验 2（`prevent_idle_sleep` 在三大桌面系统上另算实验）、已弃用 4、已移除 40。`Features::from_sources` 从默认集合出发，套上 `[features]` 表与旧顶层开关，最后补依赖，例如开了 `code_mode_only` 就自动开 `code_mode`。

改配置文件的是 `ConfigEditsBuilder`：把改动收集成 `ConfigEdit` 列表，解析符号链接找到真正的目标文件，用 `toml_edit::DocumentMut` 读入以保留注释与排版，逐条修改，确有变化才原子写回（`codex-rs/core/src/config/edit.rs:731`）。用 `ConfigEditsBuilder::for_config` 构造时，写入目标是当前生效的用户层，选了 profile 就写那份 profile 文件。关闭一个默认就关的开关时，它删掉这个键而不是写 `false`，免得开关将来转为默认开启时被旧配置钉死（`codex-rs/core/src/config/edit.rs:867`）。app-server 的 `config/value/write` 只允许写用户配置，请求带上次读到的 `expected_version`，与用户层当前的 SHA-256 版本不符就返回 `ConfigVersionConflict`，多个前端同时改配置时靠它避免互相覆盖（`codex-rs/app-server/src/config_manager_service.rs:218`）。另有 `codex-write-config-schema` 从这些类型生成 `codex-rs/core/config.schema.json` 供编辑器补全，测试 `config_schema_matches_fixture` 保证它与代码同步。

## 和《从 LLM 到 Coding Agent》对照

那本书的[权限系统](https://daiw.net/manual/llm-to-agent/permissions)把权限模式当成用户手里的总开关，从 plan 到 bypass 一拨就变。Codex 在用户之上又加了一层：requirements 在加载配置时就收窄可选的审批策略与沙箱模式，选了不允许的值会被拉回允许列表的第一项，会话里再切换也过不了 `Constrained` 的校验。OpenCode 的[配置系统](https://daiw.net/manual/opencode/config)同样多层深合并，托管目录与 macOS MDM 排在最后、覆盖一切；Codex 则把“可覆盖的默认值”和“不可越过的约束”拆成两摞，互不混用。

---

上一篇：[自动审查 · 让另一个模型替你审批](https://daiw.net/manual/codex-source/guardian) · 下一篇：[Skills · 发现、选择与注入](https://daiw.net/manual/codex-source/skills)
