Files
codex/codex-rs/core/src/config_lock.rs
jif-oai 0b04d1b3cc feat: export and replay effective config locks (#20405)
## Why

For reproducibility. A hand-written `config.toml` is not enough to
recreate what a Codex session actually ran with because layered config,
CLI overrides, defaults, feature aliases, resolved feature config,
prompt setup, and model-catalog/session values can all affect the final
runtime behavior.

This PR adds an effective config lockfile path: one run can export the
resolved session config, and a later run can replay that lockfile and
fail early if the regenerated effective config drifts.

## What Changed

- Add a dedicated `ConfigLockfileToml` wrapper with top-level lockfile
metadata plus the replayable config:

  ```toml
  version = 1
  codex_version = "..."

  [config]
  # effective ConfigToml fields
  ```

- Keep lockfile metadata out of regular `ConfigToml`; replay loads
`ConfigLockfileToml` and then uses its nested `config` as the
authoritative config layer.
- Add `debug.config_lockfile.export_dir` to write
`<thread_id>.config.lock.toml` when a root session starts.
- Add `debug.config_lockfile.load_path` to replay a saved lockfile and
validate the regenerated session lockfile against it.
- Add `debug.config_lockfile.allow_codex_version_mismatch` to optionally
tolerate Codex binary version drift while still comparing the rest of
the lockfile.
- Add `debug.config_lockfile.save_fields_resolved_from_model_catalog` so
lock creation can either save model-catalog/session-resolved fields or
intentionally leave those fields dynamic.
- Build lockfiles from the effective config plus resolved runtime values
such as model selection, reasoning settings, prompts, service tier, web
search mode, feature states/config, memories config, skill instructions,
and agent limits.
- Materialize feature aliases and custom feature config into the
lockfile so replay compares canonical resolved behavior instead of
user-authored alias shape.
- Strip profile/debug/file-include/environment-specific inputs from
generated lockfiles so they contain replayable values rather than the
inputs that produced those values.
- Surface JSON-RPC server error code/data in app-server client and TUI
bootstrap errors so config-lock replay failures include the actual TOML
diff.
- Regenerate the config schema for the new debug config keys.

## Review Notes

The main flow is split across these files:

- `config/src/config_toml.rs`: lockfile/debug TOML shapes.
- `core/src/config/mod.rs`: loading `debug.config_lockfile.*`, replaying
a lockfile as a config layer, and preserving the expected lockfile for
validation.
- `core/src/session/config_lock.rs`: exporting the current session
lockfile and materializing resolved session/config values.
- `core/src/config_lock.rs`: lockfile parsing, metadata/version checks,
replay comparison, and diff formatting.

## Usage

Export a lockfile from a normal session:

```sh
codex -c 'debug.config_lockfile.export_dir="/tmp/codex-locks"'
```

Export a lockfile without saving model-catalog/session-resolved fields:

```sh
codex -c 'debug.config_lockfile.export_dir="/tmp/codex-locks"' \
  -c 'debug.config_lockfile.save_fields_resolved_from_model_catalog=false'
```

Replay a saved lockfile in a later session:

```sh
codex -c 'debug.config_lockfile.load_path="/tmp/codex-locks/<thread_id>.config.lock.toml"'
```

If replay resolves to a different effective config, startup fails with a
TOML diff.

To tolerate Codex binary version drift during replay:

```sh
codex -c 'debug.config_lockfile.load_path="/tmp/codex-locks/<thread_id>.config.lock.toml"' \
  -c 'debug.config_lockfile.allow_codex_version_mismatch=true'
```

## Limitations

This does not support custom rules/network policies.

## Verification

- `cargo test -p codex-core config_lock`
- `cargo test -p codex-config`
- `cargo test -p codex-thread-manager-sample`
2026-05-01 17:46:02 +02:00

176 lines
5.5 KiB
Rust

use std::io;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::config_toml::ConfigLockfileToml;
use codex_config::config_toml::ConfigToml;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Serialize;
use serde::de::DeserializeOwned;
use similar::TextDiff;
pub(crate) const CONFIG_LOCK_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct ConfigLockReplayOptions {
pub allow_codex_version_mismatch: bool,
}
pub(crate) async fn read_config_lock_from_path(
path: &AbsolutePathBuf,
) -> io::Result<ConfigLockfileToml> {
let contents = tokio::fs::read_to_string(path).await.map_err(|err| {
config_lock_error(format!(
"failed to read config lock file {}: {err}",
path.display()
))
})?;
let lockfile: ConfigLockfileToml = toml::from_str(&contents).map_err(|err| {
config_lock_error(format!(
"failed to parse config lock file {}: {err}",
path.display()
))
})?;
validate_config_lock_metadata_shape(&lockfile)?;
Ok(lockfile)
}
pub(crate) fn config_lockfile(config: ConfigToml) -> ConfigLockfileToml {
ConfigLockfileToml {
version: CONFIG_LOCK_VERSION,
codex_version: env!("CARGO_PKG_VERSION").to_string(),
config,
}
}
pub(crate) fn validate_config_lock_replay(
expected_lock: &ConfigLockfileToml,
actual_lock: &ConfigLockfileToml,
options: ConfigLockReplayOptions,
) -> io::Result<()> {
validate_config_lock_metadata_shape(expected_lock)?;
validate_config_lock_metadata_shape(actual_lock)?;
if !options.allow_codex_version_mismatch
&& expected_lock.codex_version != actual_lock.codex_version
{
return Err(config_lock_error(format!(
"config lock Codex version mismatch: lock was generated by {}, current version is {}; set debug.config_lockfile.allow_codex_version_mismatch=true to ignore this",
expected_lock.codex_version, actual_lock.codex_version
)));
}
let expected_lock = config_lock_for_comparison(expected_lock, options);
let actual_lock = config_lock_for_comparison(actual_lock, options);
if expected_lock != actual_lock {
let diff = compact_diff("config", &expected_lock, &actual_lock)
.unwrap_or_else(|err| format!("failed to build config lock diff: {err}"));
return Err(config_lock_error(format!(
"replayed effective config does not match config lock: {diff}"
)));
}
Ok(())
}
pub(crate) fn lock_layer_from_config(
lock_path: &AbsolutePathBuf,
lockfile: &ConfigLockfileToml,
) -> io::Result<ConfigLayerEntry> {
let value = toml_value(
&config_without_lock_controls(&lockfile.config),
"config lock",
)?;
Ok(ConfigLayerEntry::new(
ConfigLayerSource::User {
file: lock_path.clone(),
},
value,
))
}
pub(crate) fn config_without_lock_controls(config: &ConfigToml) -> ConfigToml {
let mut config = config.clone();
clear_config_lock_debug_controls(&mut config);
config
}
pub(crate) fn clear_config_lock_debug_controls(config: &mut ConfigToml) {
if let Some(debug) = config.debug.as_mut() {
debug.config_lockfile = None;
}
if config
.debug
.as_ref()
.is_some_and(|debug| debug.config_lockfile.is_none())
{
config.debug = None;
}
}
fn validate_config_lock_metadata_shape(lock: &ConfigLockfileToml) -> io::Result<()> {
if lock.version != CONFIG_LOCK_VERSION {
return Err(config_lock_error(format!(
"unsupported config lock version {}; expected {CONFIG_LOCK_VERSION}",
lock.version
)));
}
Ok(())
}
fn config_lock_for_comparison(
lockfile: &ConfigLockfileToml,
options: ConfigLockReplayOptions,
) -> ConfigLockfileToml {
let mut lockfile = lockfile.clone();
clear_config_lock_debug_controls(&mut lockfile.config);
if options.allow_codex_version_mismatch {
lockfile.codex_version.clear();
}
lockfile
}
fn config_lock_error(message: impl Into<String>) -> io::Error {
io::Error::other(message.into())
}
fn compact_diff<T: Serialize>(root: &str, expected: &T, actual: &T) -> io::Result<String> {
let expected = toml::to_string_pretty(expected).map_err(|err| {
config_lock_error(format!(
"failed to serialize expected {root} lock TOML: {err}"
))
})?;
let actual = toml::to_string_pretty(actual).map_err(|err| {
config_lock_error(format!(
"failed to serialize actual {root} lock TOML: {err}"
))
})?;
Ok(TextDiff::from_lines(&expected, &actual)
.unified_diff()
.context_radius(2)
.header("expected", "actual")
.to_string())
}
fn toml_value<T: Serialize>(value: &T, label: &str) -> io::Result<toml::Value> {
toml::Value::try_from(value)
.map_err(|err| config_lock_error(format!("failed to serialize {label}: {err}")))
}
pub(crate) fn toml_round_trip<T>(value: &impl Serialize, label: &'static str) -> io::Result<T>
where
T: DeserializeOwned + Serialize,
{
let value = toml_value(value, label)?;
let toml = value.clone().try_into().map_err(|err| {
config_lock_error(format!("failed to convert {label} to TOML shape: {err}"))
})?;
let represented_value = toml_value(&toml, label)?;
if represented_value != value {
return Err(config_lock_error(format!(
"resolved {label} cannot be fully represented as TOML"
)));
}
Ok(toml)
}