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`
This commit is contained in:
jif-oai authored and GitHub committed 2026-05-01 17:46:02 +02:00
1 parent ff27d01676
commit 0b04d1b3cc
17 files changed
+977 -16

No files matched your search

@@ -1430,6 +1430,30 @@ async fn cli_override_model_instructions_file_sets_base_instructions() -> std::i
Ok(())
}
#[tokio::test]
async fn inline_instructions_set_base_instructions() -> std::io::Result<()> {
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"instructions = "snapshot instructions""#,
)
.await?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home)
.build()
.await?;
assert_eq!(
config.base_instructions.as_deref(),
Some("snapshot instructions")
);
Ok(())
}
#[tokio::test]
async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> std::io::Result<()> {
let tmp = tempdir()?;
+87
View File
@@ -6387,6 +6387,10 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home().to_path_buf(),
log_dir: fixture.codex_home().join("log").to_path_buf(),
config_lock_export_dir: None,
config_lock_allow_codex_version_mismatch: false,
config_lock_save_fields_resolved_from_model_catalog: true,
config_lock_toml: None,
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
@@ -6585,6 +6589,10 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home().to_path_buf(),
log_dir: fixture.codex_home().join("log").to_path_buf(),
config_lock_export_dir: None,
config_lock_allow_codex_version_mismatch: false,
config_lock_save_fields_resolved_from_model_catalog: true,
config_lock_toml: None,
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
@@ -6737,6 +6745,10 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home().to_path_buf(),
log_dir: fixture.codex_home().join("log").to_path_buf(),
config_lock_export_dir: None,
config_lock_allow_codex_version_mismatch: false,
config_lock_save_fields_resolved_from_model_catalog: true,
config_lock_toml: None,
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
@@ -6874,6 +6886,10 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home().to_path_buf(),
log_dir: fixture.codex_home().join("log").to_path_buf(),
config_lock_export_dir: None,
config_lock_allow_codex_version_mismatch: false,
config_lock_save_fields_resolved_from_model_catalog: true,
config_lock_toml: None,
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
@@ -8004,6 +8020,77 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> {
Ok(())
}
#[tokio::test]
async fn debug_config_lockfile_export_settings_load_from_nested_table() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[debug.config_lockfile]
export_dir = "locks"
allow_codex_version_mismatch = true
save_fields_resolved_from_model_catalog = false
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config.config_lock_export_dir,
Some(AbsolutePathBuf::resolve_path_against_base(
"locks",
codex_home.path()
))
);
assert!(config.config_lock_allow_codex_version_mismatch);
assert!(!config.config_lock_save_fields_resolved_from_model_catalog);
Ok(())
}
#[tokio::test]
async fn debug_config_lockfile_load_path_loads_lock_from_nested_table() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let lock_path = codex_home.path().join("session.config.lock.toml");
std::fs::write(
&lock_path,
format!(
r#"version = {}
codex_version = "older-version"
[config]
"#,
crate::config_lock::CONFIG_LOCK_VERSION
),
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[debug.config_lockfile]
load_path = '{}'
allow_codex_version_mismatch = true
save_fields_resolved_from_model_catalog = false
"#,
lock_path.display()
),
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert!(config.config_lock_toml.is_some());
assert!(config.config_lock_allow_codex_version_mismatch);
assert!(!config.config_lock_save_fields_resolved_from_model_catalog);
Ok(())
}
#[tokio::test]
async fn explicit_feature_config_is_normalized_by_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+79 -3
View File
@@ -23,7 +23,9 @@ use codex_config::ResidencyRequirement;
use codex_config::SandboxModeRequirement;
use codex_config::Sourced;
use codex_config::ThreadConfigLoader;
use codex_config::config_toml::ConfigLockfileToml;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::DEFAULT_PROJECT_DOC_MAX_BYTES;
use codex_config::config_toml::ProjectConfig;
use codex_config::config_toml::RealtimeAudioConfig;
use codex_config::config_toml::RealtimeConfig;
@@ -100,6 +102,7 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -115,6 +118,9 @@ use crate::config::permissions::default_builtin_permission_profile_name;
use crate::config::permissions::get_readable_roots_required_for_codex_runtime;
use crate::config::permissions::network_proxy_config_for_profile_selection;
use crate::config::permissions::validate_user_permission_profile_names;
use crate::config_lock::config_without_lock_controls;
use crate::config_lock::lock_layer_from_config;
use crate::config_lock::read_config_lock_from_path;
use codex_network_proxy::NetworkProxyConfig;
use toml::Value as TomlValue;
use toml_edit::DocumentMut;
@@ -162,7 +168,7 @@ impl Default for GhostSnapshotConfig {
/// Maximum number of bytes of the documentation that will be embedded. Larger
/// files are *silently truncated* to this size so we do not take up too much of
/// the context window.
pub(crate) const AGENTS_MD_MAX_BYTES: usize = 32 * 1024; // 32 KiB
pub(crate) const AGENTS_MD_MAX_BYTES: usize = DEFAULT_PROJECT_DOC_MAX_BYTES; // 32 KiB
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = Some(6);
pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION: usize = 4;
pub(crate) const DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
@@ -623,6 +629,20 @@ pub struct Config {
/// Directory where Codex writes log files (defaults to `$CODEX_HOME/log`).
pub log_dir: PathBuf,
/// Directory where Codex writes effective session config lock files.
pub config_lock_export_dir: Option<AbsolutePathBuf>,
/// Whether config lock replay ignores Codex version drift between the
/// lock metadata and the regenerated lock.
pub config_lock_allow_codex_version_mismatch: bool,
/// Whether config lock creation saves values resolved from the model
/// catalog/session configuration.
pub config_lock_save_fields_resolved_from_model_catalog: bool,
/// Effective config lock used for strict replay validation.
pub config_lock_toml: Option<Arc<ConfigLockfileToml>>,
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
pub history: History,
@@ -792,7 +812,7 @@ pub struct Config {
pub otel: codex_config::types::OtelConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MultiAgentV2Config {
pub max_concurrent_threads_per_session: usize,
pub min_wait_timeout_ms: i64,
@@ -961,6 +981,42 @@ impl ConfigBuilder {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, err));
}
};
let config_lock_settings = config_toml
.debug
.as_ref()
.and_then(|debug| debug.config_lockfile.as_ref());
if let Some(config_lock_load_path) =
config_lock_settings.and_then(|config_lock| config_lock.load_path.as_ref())
{
let allow_codex_version_mismatch = config_lock_settings
.and_then(|config_lock| config_lock.allow_codex_version_mismatch)
.unwrap_or(false);
let save_fields_resolved_from_model_catalog = config_lock_settings
.and_then(|config_lock| config_lock.save_fields_resolved_from_model_catalog)
.unwrap_or(true);
let lockfile_toml = read_config_lock_from_path(config_lock_load_path).await?;
let expected_lock_config = lockfile_toml.clone();
let lock_layer = lock_layer_from_config(config_lock_load_path, &lockfile_toml)?;
let lock_config_toml = config_without_lock_controls(&lockfile_toml.config);
let lock_config_layer_stack = ConfigLayerStack::new(
vec![lock_layer],
config_layer_stack.requirements().clone(),
config_layer_stack.requirements_toml().clone(),
)?;
let mut config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
lock_config_toml,
harness_overrides,
codex_home,
lock_config_layer_stack,
)
.await?;
config.config_lock_toml = Some(Arc::new(expected_lock_config));
config.config_lock_allow_codex_version_mismatch = allow_codex_version_mismatch;
config.config_lock_save_fields_resolved_from_model_catalog =
save_fields_resolved_from_model_catalog;
return Ok(config);
}
Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
config_toml,
@@ -2630,7 +2686,9 @@ impl Config {
"model instructions file",
)
.await?;
let base_instructions = base_instructions.or(file_base_instructions);
let base_instructions = base_instructions
.or(file_base_instructions)
.or(cfg.instructions.clone());
let developer_instructions = developer_instructions.or(cfg.developer_instructions);
let include_permissions_instructions = config_profile
.include_permissions_instructions
@@ -2902,6 +2960,24 @@ impl Config {
codex_home,
sqlite_home,
log_dir,
config_lock_export_dir: cfg
.debug
.as_ref()
.and_then(|debug| debug.config_lockfile.as_ref())
.and_then(|config_lock| config_lock.export_dir.clone()),
config_lock_allow_codex_version_mismatch: cfg
.debug
.as_ref()
.and_then(|debug| debug.config_lockfile.as_ref())
.and_then(|config_lock| config_lock.allow_codex_version_mismatch)
.unwrap_or(false),
config_lock_save_fields_resolved_from_model_catalog: cfg
.debug
.as_ref()
.and_then(|debug| debug.config_lockfile.as_ref())
.and_then(|config_lock| config_lock.save_fields_resolved_from_model_catalog)
.unwrap_or(true),
config_lock_toml: None,
config_layer_stack,
history,
ephemeral: ephemeral.unwrap_or_default(),