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
2026-05-01 17:46:02 +02:00
committed by GitHub
parent ff27d01676
commit 0b04d1b3cc
17 changed files with 977 additions and 16 deletions
@@ -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(),
+175
View File
@@ -0,0 +1,175 @@
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)
}
+1
View File
@@ -17,6 +17,7 @@ pub(crate) mod session;
pub use session::SteerInputError;
mod codex_thread;
mod compact_remote;
mod config_lock;
pub use codex_thread::CodexThread;
pub use codex_thread::CodexThreadTurnContextOverrides;
pub use codex_thread::ThreadConfigSnapshot;
+355
View File
@@ -0,0 +1,355 @@
use anyhow::Context;
use codex_config::config_toml::ConfigLockfileToml;
use codex_config::config_toml::ConfigToml;
use codex_config::types::MemoriesToml;
use codex_features::AppsMcpPathOverrideConfigToml;
use codex_features::Feature;
use codex_features::FeatureToml;
use codex_features::FeaturesToml;
use codex_features::MultiAgentV2ConfigToml;
use codex_protocol::ThreadId;
use crate::config::Config;
use crate::config_lock::ConfigLockReplayOptions;
use crate::config_lock::clear_config_lock_debug_controls;
use crate::config_lock::config_lockfile;
use crate::config_lock::toml_round_trip;
use crate::config_lock::validate_config_lock_replay;
use super::SessionConfiguration;
pub(crate) async fn validate_config_lock_if_configured(
session_configuration: &SessionConfiguration,
) -> anyhow::Result<()> {
if session_configuration.session_source.is_non_root_agent() {
return Ok(());
}
let Some(expected) = session_configuration
.original_config_do_not_use
.config_lock_toml
.as_ref()
else {
return Ok(());
};
let actual = session_configuration.to_config_lockfile_toml()?;
let config = session_configuration.original_config_do_not_use.as_ref();
let options = ConfigLockReplayOptions {
allow_codex_version_mismatch: config.config_lock_allow_codex_version_mismatch,
};
validate_config_lock_replay(expected, &actual, options)
.context("config lock replay validation failed")?;
Ok(())
}
pub(crate) async fn export_config_lock_if_configured(
session_configuration: &SessionConfiguration,
conversation_id: ThreadId,
) -> anyhow::Result<()> {
let config = session_configuration.original_config_do_not_use.as_ref();
let Some(export_dir) = config.config_lock_export_dir.as_ref() else {
return Ok(());
};
let lock = session_configuration.to_config_lockfile_toml()?;
let lock = toml::to_string_pretty(&lock).context("failed to serialize config lock")?;
let path = export_dir.join(format!("{conversation_id}.config.lock.toml"));
tokio::fs::create_dir_all(export_dir)
.await
.with_context(|| {
format!(
"failed to create config lock export directory {}",
export_dir.display()
)
})?;
tokio::fs::write(&path, lock)
.await
.with_context(|| format!("failed to write config lock to {}", path.display()))?;
Ok(())
}
impl SessionConfiguration {
pub(crate) fn to_config_lockfile_toml(&self) -> anyhow::Result<ConfigLockfileToml> {
Ok(config_lockfile(session_configuration_to_lock_config_toml(
self,
)?))
}
}
fn session_configuration_to_lock_config_toml(
sc: &SessionConfiguration,
) -> anyhow::Result<ConfigToml> {
let config = sc.original_config_do_not_use.as_ref();
// Start from the resolved layer stack, then patch in values that are only
// known after session setup. Export and replay validation both use this
// path, so every field here is part of the lockfile contract.
let mut lock_config: ConfigToml = config
.config_layer_stack
.effective_config()
.try_into()
.context("failed to deserialize effective config for config lock")?;
if config.config_lock_save_fields_resolved_from_model_catalog {
save_session_resolved_fields(sc, &mut lock_config);
}
save_config_resolved_fields(config, &mut lock_config)?;
drop_lockfile_inputs(&mut lock_config);
Ok(lock_config)
}
/// Saves values chosen during session construction from the model catalog,
/// collaboration mode, and resolved prompt setup.
///
/// These values are not always present in the raw layer stack, so copy them
/// from the live session when the lockfile should be fully self-contained.
fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut ConfigToml) {
lock_config.model = Some(sc.collaboration_mode.model().to_string());
lock_config.model_reasoning_effort = sc.collaboration_mode.reasoning_effort();
lock_config.model_reasoning_summary = sc.model_reasoning_summary;
lock_config.service_tier = sc.service_tier;
lock_config.instructions = Some(sc.base_instructions.clone());
lock_config.developer_instructions = sc.developer_instructions.clone();
lock_config.compact_prompt = sc.compact_prompt.clone();
lock_config.personality = sc.personality;
lock_config.approval_policy = Some(sc.approval_policy.value());
lock_config.approvals_reviewer = Some(sc.approvals_reviewer);
}
/// Saves values stored on `Config` after higher-level resolution,
/// normalization, defaulting, or feature materialization.
///
/// Persist the resolved representation so replay compares against the behavior
/// Codex actually ran with, not only the user-authored TOML inputs.
fn save_config_resolved_fields(
config: &Config,
lock_config: &mut ConfigToml,
) -> anyhow::Result<()> {
lock_config.web_search = Some(config.web_search_mode.value());
lock_config.model_provider = Some(config.model_provider_id.clone());
lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort;
lock_config.model_verbosity = config.model_verbosity;
lock_config.include_permissions_instructions = Some(config.include_permissions_instructions);
lock_config.include_apps_instructions = Some(config.include_apps_instructions);
lock_config.include_environment_context = Some(config.include_environment_context);
lock_config.background_terminal_max_timeout = Some(config.background_terminal_max_timeout);
// Feature aliases and feature configs need to be written in their resolved
// form; otherwise replay can drift when a legacy key maps to the same
// runtime feature.
let features = lock_config
.features
.get_or_insert_with(FeaturesToml::default);
features.materialize_resolved_enabled(config.features.get());
let mut multi_agent_v2: MultiAgentV2ConfigToml =
resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?;
multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2));
features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2));
features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml {
enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)),
path: config.apps_mcp_path_override.clone(),
}));
lock_config.memories = Some(resolved_config_to_toml::<MemoriesToml>(
&config.memories,
"memories",
)?);
let agents = lock_config.agents.get_or_insert_with(Default::default);
// Multi-agent v2 owns thread fanout through its feature config. Preserve
// the legacy agents.max_threads setting only when v2 is disabled.
agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) {
None
} else {
config.agent_max_threads
};
agents.max_depth = Some(config.agent_max_depth);
agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds;
agents.interrupt_message = Some(config.agent_interrupt_message_enabled);
lock_config
.skills
.get_or_insert_with(Default::default)
.include_instructions = Some(config.include_skill_instructions);
Ok(())
}
fn drop_lockfile_inputs(lock_config: &mut ConfigToml) {
// The lockfile should contain replayable values, not the profile,
// debug-control, file-include, and environment-specific inputs that
// produced those values in the original session.
lock_config.profile = None;
lock_config.profiles.clear();
clear_config_lock_debug_controls(lock_config);
lock_config.model_instructions_file = None;
lock_config.experimental_instructions_file = None;
lock_config.experimental_compact_prompt_file = None;
lock_config.model_catalog_json = None;
lock_config.sandbox_mode = None;
lock_config.sandbox_workspace_write = None;
lock_config.default_permissions = None;
lock_config.permissions = None;
lock_config.experimental_use_unified_exec_tool = None;
lock_config.experimental_use_freeform_apply_patch = None;
}
fn resolved_config_to_toml<Toml>(
value: &impl serde::Serialize,
label: &'static str,
) -> anyhow::Result<Toml>
where
Toml: serde::de::DeserializeOwned + serde::Serialize,
{
toml_round_trip(value, label).map_err(anyhow::Error::from)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::sync::Arc;
#[tokio::test]
async fn lock_contains_prompts_and_materializes_features() {
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
sc.base_instructions = "resolved instructions".to_string();
sc.developer_instructions = Some("resolved developer instructions".to_string());
sc.compact_prompt = Some("resolved compact prompt".to_string());
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
let lock = &lockfile.config;
assert_eq!(lock.instructions, Some(sc.base_instructions.clone()));
assert_eq!(lock.developer_instructions, sc.developer_instructions);
assert_eq!(lock.compact_prompt, sc.compact_prompt);
assert_eq!(lock.model, Some(sc.collaboration_mode.model().to_string()));
assert_eq!(
lock.model_reasoning_effort,
sc.collaboration_mode.reasoning_effort()
);
assert_eq!(lock.profile, None);
assert!(lock.profiles.is_empty());
assert!(
lock.debug
.as_ref()
.is_none_or(|debug| debug.config_lockfile.is_none())
);
assert!(lock.memories.is_some());
let features = lock
.features
.as_ref()
.expect("lock should materialize feature states");
let feature_entries = features.entries();
for spec in codex_features::FEATURES {
assert_eq!(
feature_entries.get(spec.key),
Some(&sc.original_config_do_not_use.features.enabled(spec.id)),
"{}",
spec.key
);
}
let multi_agent_v2 = features
.multi_agent_v2
.as_ref()
.expect("multi_agent_v2 config should be materialized");
assert!(matches!(
multi_agent_v2,
FeatureToml::Config(MultiAgentV2ConfigToml {
enabled: Some(false),
max_concurrent_threads_per_session: Some(_),
min_wait_timeout_ms: Some(_),
usage_hint_enabled: Some(_),
hide_spawn_agent_metadata: Some(_),
..
})
));
assert_eq!(lockfile.version, crate::config_lock::CONFIG_LOCK_VERSION);
}
#[tokio::test]
async fn lock_skips_session_values_when_model_catalog_fields_are_not_saved() {
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
let mut config = (*sc.original_config_do_not_use).clone();
config.config_lock_save_fields_resolved_from_model_catalog = false;
sc.original_config_do_not_use = Arc::new(config);
sc.base_instructions = "catalog instructions".to_string();
sc.developer_instructions = Some("catalog developer instructions".to_string());
sc.compact_prompt = Some("catalog compact prompt".to_string());
sc.service_tier = Some(codex_protocol::config_types::ServiceTier::Flex);
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
let lock = &lockfile.config;
assert_eq!(lock.model, None);
assert_eq!(lock.model_reasoning_effort, None);
assert_eq!(lock.model_reasoning_summary, None);
assert_eq!(lock.service_tier, None);
assert_eq!(lock.instructions, None);
assert_eq!(lock.developer_instructions, None);
assert_eq!(lock.compact_prompt, None);
assert_eq!(lock.personality, None);
assert_eq!(lock.approval_policy, None);
assert_eq!(lock.approvals_reviewer, None);
}
#[tokio::test]
async fn lock_validation_reports_config_diff() {
let sc = crate::session::tests::make_session_configuration_for_tests().await;
let expected = sc.to_config_lockfile_toml().expect("lock should serialize");
let mut actual = expected.clone();
actual.config.model = Some("different-model".to_string());
let error =
validate_config_lock_replay(&expected, &actual, ConfigLockReplayOptions::default())
.expect_err("config drift should fail");
let message = error.to_string();
assert!(
message.contains("replayed effective config does not match config lock"),
"{message}"
);
assert!(message.contains("model = "), "{message}");
}
#[tokio::test]
async fn lock_validation_rejects_codex_version_mismatch_by_default() {
let sc = crate::session::tests::make_session_configuration_for_tests().await;
let mut expected = sc.to_config_lockfile_toml().expect("lock should serialize");
expected.codex_version = "older-version".to_string();
let actual = sc.to_config_lockfile_toml().expect("lock should serialize");
let error =
validate_config_lock_replay(&expected, &actual, ConfigLockReplayOptions::default())
.expect_err("version drift should fail");
let message = error.to_string();
assert!(
message.contains("config lock Codex version mismatch"),
"{message}"
);
assert!(
message.contains("debug.config_lockfile.allow_codex_version_mismatch=true"),
"{message}"
);
}
#[tokio::test]
async fn lock_validation_can_ignore_codex_version_mismatch() {
let sc = crate::session::tests::make_session_configuration_for_tests().await;
let mut expected = sc.to_config_lockfile_toml().expect("lock should serialize");
expected.codex_version = "older-version".to_string();
let actual = sc.to_config_lockfile_toml().expect("lock should serialize");
validate_config_lock_replay(
&expected,
&actual,
ConfigLockReplayOptions {
allow_codex_version_mismatch: true,
},
)
.expect("version drift should be ignored");
}
}
+3
View File
@@ -185,6 +185,7 @@ use codex_protocol::error::Result as CodexResult;
#[cfg(test)]
use codex_protocol::exec_output::StreamOutput;
mod config_lock;
mod handlers;
mod mcp;
mod multi_agents;
@@ -194,6 +195,8 @@ mod rollout_reconstruction;
pub(crate) mod session;
pub(crate) mod turn;
pub(crate) mod turn_context;
use self::config_lock::export_config_lock_if_configured;
use self::config_lock::validate_config_lock_if_configured;
#[cfg(test)]
use self::handlers::submission_dispatch_span;
use self::handlers::submission_loop;
+2
View File
@@ -723,6 +723,8 @@ impl Session {
))
.await;
session_configuration.thread_name = thread_name.clone();
validate_config_lock_if_configured(&session_configuration).await?;
export_config_lock_if_configured(&session_configuration, conversation_id).await?;
let state = SessionState::new(session_configuration.clone());
let managed_network_requirements_configured = config
.config_layer_stack