Remove config lockfile support (#38011)

## What changed

- Remove effective-config lockfile export, replay, and validation from session startup.
- Remove the `debug.config_lockfile` settings and generated schema entries.
- Remove feature-config materialization helpers that were only used to create lockfiles.

GitOrigin-RevId: a8c07c1c06325b3ec3dae9a97c36c2488d37df25
This commit is contained in:
jif
2026-08-11 13:52:48 +00:00
committed by copyberry
parent 3d4d253f8f
commit 279b93242c
17 changed files with 1 additions and 1421 deletions
+1 -2
View File
@@ -1417,8 +1417,7 @@ impl ConfigRequirementsToml {
/// Applies the requirements whose values replace config values.
///
/// This projection is shared by config/read and config-lock export so
/// both surfaces describe the same behavior as the final runtime config.
/// This projection keeps config/read aligned with the final runtime config.
pub fn apply_exact_to_config(&self, config: &mut ConfigToml) {
macro_rules! apply_exact {
($field:ident) => {
-40
View File
@@ -46,7 +46,6 @@ use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::BaseInstructionsProvenance;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::NetworkSandboxPolicy;
@@ -327,9 +326,6 @@ pub struct ConfigToml {
/// Defaults to `$CODEX_HOME/log`.
pub log_dir: Option<AbsolutePathBuf>,
/// Debugging and reproducibility settings.
pub debug: Option<DebugToml>,
/// Optional URI-based file opener. If set, citations to files in the model
/// output will be hyperlinked using the specified URI scheme.
pub file_opener: Option<UriBasedFileOpener>,
@@ -518,42 +514,6 @@ pub struct ConfigToml {
pub oss_provider: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ConfigLockfileToml {
pub version: u32,
pub codex_version: String,
/// Origin of the effective base instructions captured in the lockfile.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_instructions_provenance: Option<BaseInstructionsProvenance>,
/// Replayable effective config captured in the lockfile.
pub config: ConfigToml,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct DebugToml {
pub config_lockfile: Option<DebugConfigLockToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct DebugConfigLockToml {
/// Directory where Codex writes effective session config lock files.
pub export_dir: Option<AbsolutePathBuf>,
/// Lockfile to replay as the authoritative effective config.
pub load_path: Option<AbsolutePathBuf>,
/// Allow replaying a lock generated by a different Codex version.
pub allow_codex_version_mismatch: Option<bool>,
/// Save fields resolved from the model catalog/session configuration.
pub save_fields_resolved_from_model_catalog: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ThreadStoreToml {
-47
View File
@@ -943,45 +943,6 @@
],
"type": "string"
},
"DebugConfigLockToml": {
"additionalProperties": false,
"properties": {
"allow_codex_version_mismatch": {
"description": "Allow replaying a lock generated by a different Codex version.",
"type": "boolean"
},
"export_dir": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Directory where Codex writes effective session config lock files."
},
"load_path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Lockfile to replay as the authoritative effective config."
},
"save_fields_resolved_from_model_catalog": {
"description": "Save fields resolved from the model catalog/session configuration.",
"type": "boolean"
}
},
"type": "object"
},
"DebugToml": {
"additionalProperties": false,
"properties": {
"config_lockfile": {
"$ref": "#/definitions/DebugConfigLockToml"
}
},
"type": "object"
},
"ExperimentalRequestUserInput": {
"additionalProperties": false,
"properties": {
@@ -5040,14 +5001,6 @@
"description": "Compact prompt used for history compaction.",
"type": "string"
},
"debug": {
"allOf": [
{
"$ref": "#/definitions/DebugToml"
}
],
"description": "Debugging and reproducibility settings."
},
"default_permissions": {
"description": "Default permissions profile to apply. Names starting with `:` refer to built-in profiles; other names are resolved from the `[permissions]` table.",
"type": "string"
-71
View File
@@ -10592,77 +10592,6 @@ browser_use_full_cdp_access = false
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()?;
-75
View File
@@ -21,7 +21,6 @@ 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;
@@ -142,9 +141,6 @@ 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 crate::responses_metadata::validate_extra_metadata;
use codex_network_proxy::NetworkProxyConfig;
use toml::Value as TomlValue;
@@ -915,20 +911,6 @@ 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,
@@ -1520,45 +1502,6 @@ 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?;
if let Some(provenance) = lockfile_toml.base_instructions_provenance {
config.base_instructions_provenance = Some(provenance);
}
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,
@@ -4223,24 +4166,6 @@ impl Config {
codex_home,
sqlite: codex_state::SqliteConfig::from_sqlite_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(),
-183
View File
@@ -1,183 +0,0 @@
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(),
base_instructions_provenance: None,
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 mut actual_lock = config_lock_for_comparison(actual_lock, options);
if expected_lock.base_instructions_provenance.is_none() {
actual_lock.base_instructions_provenance = None;
}
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(),
profile: None,
},
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 let Some(features) = lockfile.config.features.as_mut() {
features.clear_removed_compatibility_entries();
}
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
@@ -25,7 +25,6 @@ mod compact_remote;
mod compact_remote_history;
mod compact_remote_v2;
mod compact_token_budget;
mod config_lock;
mod user_message_admission;
pub use codex_thread::BackgroundTerminalInfo;
pub use codex_thread::CodexThread;
-641
View File
@@ -1,641 +0,0 @@
use anyhow::Context;
use codex_config::config_toml::ConfigLockfileToml;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::OrchestratorFeatureToml;
use codex_config::config_toml::OrchestratorToml;
use codex_config::types::MemoriesToml;
use codex_features::CurrentTimeReminderConfigToml;
use codex_features::Feature;
use codex_features::FeatureToml;
use codex_features::FeaturesToml;
use codex_features::MultiAgentV2ConfigToml;
use codex_features::RolloutBudgetConfigToml;
use codex_features::TokenBudgetConfigToml;
use codex_features::ToolRegistryConfigToml;
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructionsProvenance;
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,
base_instructions_provenance: Option<&BaseInstructionsProvenance>,
) -> 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 mut actual = session_configuration.to_config_lockfile_toml()?;
if actual.config.instructions.is_some() {
actual.base_instructions_provenance = base_instructions_provenance.cloned();
}
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,
base_instructions_provenance: Option<&BaseInstructionsProvenance>,
) -> 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 mut lock = session_configuration.to_config_lockfile_toml()?;
if lock.config.instructions.is_some() {
lock.base_instructions_provenance = base_instructions_provenance.cloned();
}
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);
// Apply exact managed values last so cleanup cannot discard their runtime-effective values.
config
.config_layer_stack
.requirements_toml()
.apply_exact_to_config(&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.clone();
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.clone();
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_collaboration_mode_instructions =
Some(config.include_collaboration_mode_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());
if config.tool_registry.error_on_tool_collisions
|| config.tool_registry.turn_metadata_includes_tool_info
|| features.tool_registry.is_some()
{
features.tool_registry = Some(ToolRegistryConfigToml {
error_on_tool_collisions: Some(config.tool_registry.error_on_tool_collisions),
turn_metadata_includes_tool_info: Some(
config.tool_registry.turn_metadata_includes_tool_info,
),
});
}
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));
if let Some(token_budget) = config.token_budget.as_ref()
&& super::token_budget::has_explicit_settings(config)
{
let mut token_budget: TokenBudgetConfigToml =
resolved_config_to_toml(token_budget, "features.token_budget")?;
token_budget.enabled = Some(config.features.enabled(Feature::TokenBudget));
features.token_budget = Some(FeatureToml::Config(token_budget));
}
if let Some(rollout_budget) = config.rollout_budget.as_ref() {
let mut rollout_budget: RolloutBudgetConfigToml =
resolved_config_to_toml(rollout_budget, "features.rollout_budget")?;
rollout_budget.enabled = Some(config.features.enabled(Feature::RolloutBudget));
features.rollout_budget = Some(FeatureToml::Config(rollout_budget));
}
if let Some(current_time_reminder) = config.current_time_reminder.as_ref() {
let mut current_time_reminder: CurrentTimeReminderConfigToml =
resolved_config_to_toml(current_time_reminder, "features.current_time_reminder")?;
current_time_reminder.enabled = Some(config.features.enabled(Feature::CurrentTimeReminder));
features.current_time_reminder = Some(FeatureToml::Config(current_time_reminder));
}
lock_config.memories = Some(resolved_config_to_toml::<MemoriesToml>(
&config.memories,
"memories",
)?);
let agents = lock_config.agents.get_or_insert_with(Default::default);
agents.enabled = Some(config.agents_enabled);
agents.max_concurrent_threads_per_session = config.agent_max_threads;
agents.max_depth = Some(config.agent_max_depth);
agents.default_subagent_model = config.agent_default_subagent_model.clone();
agents.default_subagent_reasoning_effort =
config.agent_default_subagent_reasoning_effort.clone();
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);
lock_config
.orchestrator
.get_or_insert_with(OrchestratorToml::default)
.skills
.get_or_insert_with(OrchestratorFeatureToml::default)
.enabled = Some(config.orchestrator_skills_enabled);
lock_config
.orchestrator
.get_or_insert_with(OrchestratorToml::default)
.mcp
.get_or_insert_with(Default::default)
.enabled = Some(config.orchestrator_mcp_enabled);
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_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;
}
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 codex_config::test_support::CloudConfigBundleFixture;
use codex_models_manager::bundled_models_response;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::sync::Arc;
fn write_model_catalog(path: &Path) {
let mut catalog = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
catalog.models = catalog.models.into_iter().take(1).collect();
std::fs::write(
path,
serde_json::to_string(&catalog).expect("serialize model catalog"),
)
.expect("write model catalog");
}
#[tokio::test]
async fn lock_contains_prompts_and_materializes_features() {
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
let mut config = (*sc.original_config_do_not_use).clone();
config.tool_registry.error_on_tool_collisions = true;
config.tool_registry.turn_metadata_includes_tool_info = true;
config.multi_agent_v2.subagent_developer_instructions =
Some("Locked subagent developer instructions.".to_string());
config.token_budget = Some(crate::config::TokenBudgetConfig {
reminder_threshold_tokens: Some(16_000),
reminder_message_template: "Locked reminder: {n_remaining} tokens.".to_string(),
guidance_message: Some("Locked context-window guidance.".to_string()),
auto_compact_fallback_prompt: Some("Write notes before rollover.".to_string()),
auto_compact_fallback_buffer_tokens: Some(8_000),
});
config
.features
.enable(Feature::TokenBudget)
.expect("token_budget should be enableable in tests");
config.rollout_budget = Some(crate::config::RolloutBudgetConfig {
limit_tokens: 100_000,
reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000],
sampling_token_weight: 1.0,
prefill_token_weight: 0.25,
});
config
.features
.enable(Feature::RolloutBudget)
.expect("rollout_budget should be enableable in tests");
config.current_time_reminder = Some(crate::config::CurrentTimeReminderConfig::default());
config
.features
.enable(Feature::CurrentTimeReminder)
.expect("current_time_reminder should be enableable in tests");
sc.original_config_do_not_use = Arc::new(config);
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");
assert_eq!(
features.tool_registry,
Some(ToolRegistryConfigToml {
error_on_tool_collisions: Some(true),
turn_metadata_includes_tool_info: Some(true),
})
);
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
);
}
assert_eq!(
features.code_mode,
Some(FeatureToml::Enabled(
sc.original_config_do_not_use
.features
.enabled(Feature::CodeMode)
))
);
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(_),
max_wait_timeout_ms: Some(_),
default_wait_timeout_ms: Some(_),
subagent_developer_instructions: Some(instructions),
hide_spawn_agent_metadata: Some(_),
..
}) if instructions == "Locked subagent developer instructions."
));
assert_eq!(
features.token_budget,
Some(FeatureToml::Config(TokenBudgetConfigToml {
enabled: Some(true),
reminder_threshold_tokens: Some(16_000),
reminder_message_template: Some(
"Locked reminder: {n_remaining} tokens.".to_string()
),
guidance_message: Some("Locked context-window guidance.".to_string()),
auto_compact_fallback_prompt: Some("Write notes before rollover.".to_string()),
auto_compact_fallback_buffer_tokens: Some(8_000),
}))
);
assert_eq!(
features.rollout_budget,
Some(FeatureToml::Config(RolloutBudgetConfigToml {
enabled: Some(true),
limit_tokens: Some(100_000),
reminder_at_remaining_tokens: Some(vec![50_000, 25_000, 10_000]),
sampling_token_weight: Some(1.0),
prefill_token_weight: Some(0.25),
}))
);
assert_eq!(
features.current_time_reminder,
Some(FeatureToml::Config(CurrentTimeReminderConfigToml {
enabled: Some(true),
reminder_interval_seconds: Some(1),
clock_source: Some(codex_features::CurrentTimeSource::System),
delivery_mode: Some(codex_features::CurrentTimeReminderDeliveryMode::AnyInference),
sleep_tool: Some(false),
}))
);
assert_eq!(lockfile.version, crate::config_lock::CONFIG_LOCK_VERSION);
}
#[tokio::test]
async fn lock_preserves_model_owned_token_budget_defaults() {
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
let mut config = (*sc.original_config_do_not_use).clone();
config.token_budget = Some(crate::config::TokenBudgetConfig::default());
config
.features
.enable(Feature::TokenBudget)
.expect("token_budget should be enableable in tests");
sc.original_config_do_not_use = Arc::new(config);
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
let features = lockfile
.config
.features
.as_ref()
.expect("lock should materialize feature states");
assert_eq!(
features.token_budget.as_ref(),
Some(&FeatureToml::Enabled(true))
);
assert_eq!(features.tool_registry, None);
}
#[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("flex".to_string());
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_contains_exact_managed_requirements() {
let codex_home = tempfile::tempdir().expect("create temp dir");
let sqlite_home = codex_home.path().join("managed-state");
let log_dir = codex_home.path().join("managed-logs");
let catalog_path = codex_home.path().join("managed-models.json");
write_model_catalog(&catalog_path);
let requirements = format!(
r#"
sqlite_home = {:?}
log_dir = {:?}
model_catalog_json = {:?}
check_for_update_on_startup = false
allow_login_shell = false
[feedback]
enabled = false
[windows]
sandbox_private_desktop = false
"#,
sqlite_home.display(),
log_dir.display(),
catalog_path.display(),
);
let mut config = crate::config::ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements),
)
.build()
.await
.expect("config should load");
config.config_lock_save_fields_resolved_from_model_catalog = false;
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
sc.original_config_do_not_use = Arc::new(config);
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
let lock = &lockfile.config;
assert_eq!(lock.sqlite_home.as_deref(), Some(sqlite_home.as_path()));
assert_eq!(lock.log_dir.as_deref(), Some(log_dir.as_path()));
assert_eq!(
lock.model_catalog_json.as_deref(),
Some(catalog_path.as_path())
);
assert_eq!(lock.check_for_update_on_startup, Some(false));
assert_eq!(lock.allow_login_shell, Some(false));
assert_eq!(
lock.feedback.as_ref().and_then(|feedback| feedback.enabled),
Some(false)
);
assert_eq!(
lock.windows
.as_ref()
.and_then(|windows| windows.sandbox_private_desktop),
Some(false)
);
}
#[tokio::test]
async fn lock_drops_unmanaged_model_catalog_input() {
let codex_home = tempfile::tempdir().expect("create temp dir");
let catalog_path = codex_home.path().join("user-models.json");
write_model_catalog(&catalog_path);
let config = crate::config::ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![(
"model_catalog_json".to_string(),
toml::Value::String(catalog_path.display().to_string()),
)])
.build()
.await
.expect("config should load");
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
sc.original_config_do_not_use = Arc::new(config);
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
assert_eq!(lockfile.config.model_catalog_json, 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_ignores_removed_apps_mcp_path_override() {
let sc = crate::session::tests::make_session_configuration_for_tests().await;
let actual = sc.to_config_lockfile_toml().expect("lock should serialize");
let mut expected_value = toml::Value::try_from(&actual).expect("lock should become TOML");
expected_value["config"]["features"]
.as_table_mut()
.expect("features should be a table")
.insert(
"apps_mcp_path_override".to_string(),
toml::Value::Table(toml::Table::from_iter([
("enabled".to_string(), toml::Value::Boolean(true)),
(
"path".to_string(),
toml::Value::String("/custom/mcp".to_string()),
),
])),
);
let expected: ConfigLockfileToml = expected_value
.try_into()
.expect("lock with removed input should deserialize");
validate_config_lock_replay(&expected, &actual, ConfigLockReplayOptions::default())
.expect("removed compatibility input should not cause lock drift");
}
#[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");
}
}
-9
View File
@@ -204,7 +204,6 @@ use codex_protocol::error::Result as CodexResult;
use codex_protocol::exec_output::StreamOutput;
mod code_mode_warning;
mod config_lock;
pub(crate) mod context_window;
mod extension_metrics;
mod handlers;
@@ -227,8 +226,6 @@ pub(crate) mod turn;
pub(crate) mod turn_context;
mod world_state;
use self::code_mode_warning::unsupported_code_mode_warning;
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;
@@ -683,11 +680,6 @@ impl Session {
.get_model_info(model.as_str(), &config.to_models_manager_config())
.await;
let configured_config = Arc::clone(&config);
if config.config_lock_export_dir.is_some()
&& config.config_lock_save_fields_resolved_from_model_catalog
{
self::token_budget::apply_model_defaults(Arc::make_mut(&mut config), &model_info);
}
let multi_agent_version = config.multi_agent_version_override().or_else(|| {
resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version)
});
@@ -735,7 +727,6 @@ impl Session {
developer_instructions: config.developer_instructions.clone(),
personality: config.personality,
base_instructions,
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
permission_profile_state: session_permission_profile_state_from_config(&config)?,
-14
View File
@@ -85,9 +85,6 @@ pub(crate) struct SessionConfiguration {
/// Base instructions for the session.
pub(super) base_instructions: String,
/// Compact prompt override.
pub(super) compact_prompt: Option<String>,
/// When to escalate for approval for execution
pub(super) approval_policy: Constrained<AskForApproval>,
pub(super) approvals_reviewer: ApprovalsReviewer,
@@ -1100,17 +1097,6 @@ impl Session {
);
}
session_configuration.thread_name = thread_name.clone();
validate_config_lock_if_configured(
&session_configuration,
base_instructions_provenance.as_ref(),
)
.await?;
export_config_lock_if_configured(
&session_configuration,
thread_id,
base_instructions_provenance.as_ref(),
)
.await?;
let mut state = SessionState::new_with_auto_compact_window_ids(
session_configuration.clone(),
initial_auto_compact_window_ids,
-8
View File
@@ -4099,7 +4099,6 @@ async fn set_rate_limits_retains_previous_credits() {
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
permission_profile_state: config.permissions.permission_profile_state().clone(),
@@ -4209,7 +4208,6 @@ async fn set_rate_limits_updates_plan_type_when_present() {
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
permission_profile_state: config.permissions.permission_profile_state().clone(),
@@ -4758,7 +4756,6 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
permission_profile_state: config.permissions.permission_profile_state().clone(),
@@ -5555,7 +5552,6 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() {
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
permission_profile_state: config.permissions.permission_profile_state().clone(),
@@ -5697,7 +5693,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
permission_profile_state: config.permissions.permission_profile_state().clone(),
@@ -5977,7 +5972,6 @@ async fn make_session_with_config_and_rx(
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
permission_profile_state: config.permissions.permission_profile_state().clone(),
@@ -6092,7 +6086,6 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
permission_profile_state: config.permissions.permission_profile_state().clone(),
@@ -7957,7 +7950,6 @@ where
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
approvals_reviewer: config.approvals_reviewer,
permission_profile_state: config.permissions.permission_profile_state().clone(),
-1
View File
@@ -619,7 +619,6 @@ async fn review_uses_updated_turn_permissions_and_approval_policy() {
.with_config(|config| {
config.review_model = Some("gpt-5.4".to_string());
config.model_context_window = Some(128_000);
config.config_lock_export_dir = Some(config.codex_home.join("review-config-locks"));
config.service_tier = Some(ServiceTier::Fast.request_value().to_string());
config
.features
-124
View File
@@ -1,13 +1,10 @@
use anyhow::Result;
use codex_config::config_toml::ConfigLockfileToml;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core::config::Config;
use codex_core::config::TokenBudgetConfig;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_features::Feature;
use codex_features::FeatureToml;
use codex_features::TokenBudgetConfigToml;
use codex_model_provider_info::built_in_model_providers;
use codex_protocol::config_types::AutoCompactTokenLimitScope;
use codex_protocol::items::TurnItem;
@@ -58,7 +55,6 @@ use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
const CONFIGURED_CONTEXT_WINDOW: i64 = 128_000;
const AUTO_COMPACT_FALLBACK_PROMPT: &str = "Save the important state before rollover.";
@@ -382,126 +378,6 @@ async fn token_budget_explicit_default_template_overrides_model_defaults() -> Re
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn token_budget_model_defaults_survive_config_lock_replay() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let responses = mount_sse_sequence(
&server,
vec![sse_completed("resp-1"), sse_completed("resp-2")],
)
.await;
let home = Arc::new(TempDir::new()?);
let initial = test_codex()
.with_home(Arc::clone(&home))
.with_model_info_override("gpt-5.2", |model_info| {
model_info
.model_messages
.as_mut()
.expect("bundled model should have model messages")
.token_budget = Some(model_token_budget_config());
})
.with_config(|config| {
config.model_context_window = Some(CONFIGURED_CONTEXT_WINDOW);
config.config_lock_export_dir = Some(config.codex_home.join("memento-config-locks"));
config
.features
.enable(Feature::TokenBudget)
.expect("test config should allow token budget");
})
.build_with_auto_env(&server)
.await?;
initial
.submit_turn("inspect guidance before lock replay")
.await?;
let lock_path = initial
.codex_home_path()
.join("memento-config-locks")
.join(format!(
"{}.config.lock.toml",
initial.session_configured.thread_id
));
let lock: ConfigLockfileToml = toml::from_str(&std::fs::read_to_string(&lock_path)?)?;
assert_eq!(
lock.config
.features
.as_ref()
.and_then(|features| features.token_budget.as_ref()),
Some(&FeatureToml::Config(TokenBudgetConfigToml {
enabled: Some(true),
reminder_threshold_tokens: Some(6_144),
reminder_message_template: Some(
"Model reminder: {n_remaining} tokens remain.".to_string()
),
guidance_message: Some("Use the model-owned context-window guidance.".to_string()),
auto_compact_fallback_prompt: Some(AUTO_COMPACT_FALLBACK_PROMPT.to_string()),
auto_compact_fallback_buffer_tokens: Some(16_384),
}))
);
let replay = test_codex()
.with_home(home)
.with_pre_build_hook(move |home| {
std::fs::write(
home.join("config.toml"),
format!("[debug.config_lockfile]\nload_path = {lock_path:?}\n"),
)
.expect("write exported config lock replay settings");
})
.with_model_info_override("gpt-5.2", |model_info| {
let mut updated_defaults = model_token_budget_config();
updated_defaults.reminder_threshold_tokens = 4_096;
updated_defaults.reminder_message_template =
"Updated model reminder: {n_remaining} tokens remain.".to_string();
updated_defaults.guidance_message =
"Use the updated model-owned context-window guidance.".to_string();
updated_defaults.auto_compact_fallback_prompt =
"Use the updated fallback before rollover.".to_string();
updated_defaults.auto_compact_fallback_buffer_tokens = 8_192;
model_info
.model_messages
.as_mut()
.expect("bundled model should have model messages")
.token_budget = Some(updated_defaults);
})
.with_config(|config| {
config.model_context_window = Some(CONFIGURED_CONTEXT_WINDOW);
})
.build_with_auto_env(&server)
.await?;
core_test_support::submit_thread_settings(
&replay.codex,
ThreadSettingsOverrides {
model: Some("gpt-5.4".to_string()),
..Default::default()
},
)
.await?;
replay
.submit_text_turn("inspect guidance after lock replay and model switch")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
for request in &requests {
assert!(
request.body_contains_text("Use the model-owned context-window guidance."),
"exporting and replaying a config lock must preserve model-owned guidance"
);
}
assert_eq!(requests[1].body_json()["model"], "gpt-5.4");
assert!(
requests[1]
.message_input_texts("developer")
.iter()
.any(|text| text.contains("<model_switch>")),
"replaying model-owned instructions must not override the new model's template"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn token_budget_defaults_follow_the_active_model() -> Result<()> {
skip_if_no_network!(Ok(()));
-32
View File
@@ -37,10 +37,6 @@ impl FeatureConfig for CodeModeConfigToml {
fn enabled(&self) -> Option<bool> {
self.enabled
}
fn set_enabled(&mut self, enabled: bool) {
self.enabled = Some(enabled);
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
@@ -57,10 +53,6 @@ impl FeatureConfig for CodeModeHostConfigToml {
fn enabled(&self) -> Option<bool> {
self.enabled
}
fn set_enabled(&mut self, enabled: bool) {
self.enabled = Some(enabled);
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
@@ -77,10 +69,6 @@ impl FeatureConfig for NonPrefixedMcpToolNamesConfigToml {
fn enabled(&self) -> Option<bool> {
self.enabled
}
fn set_enabled(&mut self, enabled: bool) {
self.enabled = Some(enabled);
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
@@ -134,10 +122,6 @@ impl FeatureConfig for MultiAgentV2ConfigToml {
fn enabled(&self) -> Option<bool> {
self.enabled
}
fn set_enabled(&mut self, enabled: bool) {
self.enabled = Some(enabled);
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
@@ -172,10 +156,6 @@ impl FeatureConfig for TokenBudgetConfigToml {
fn enabled(&self) -> Option<bool> {
self.enabled
}
fn set_enabled(&mut self, enabled: bool) {
self.enabled = Some(enabled);
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
@@ -201,10 +181,6 @@ impl FeatureConfig for RolloutBudgetConfigToml {
fn enabled(&self) -> Option<bool> {
self.enabled
}
fn set_enabled(&mut self, enabled: bool) {
self.enabled = Some(enabled);
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
@@ -246,10 +222,6 @@ impl FeatureConfig for CurrentTimeReminderConfigToml {
fn enabled(&self) -> Option<bool> {
self.enabled
}
fn set_enabled(&mut self, enabled: bool) {
self.enabled = Some(enabled);
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
@@ -294,10 +266,6 @@ impl FeatureConfig for NetworkProxyConfigToml {
fn enabled(&self) -> Option<bool> {
self.enabled
}
fn set_enabled(&mut self, enabled: bool) {
self.enabled = Some(enabled);
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
-67
View File
@@ -706,13 +706,6 @@ impl Features {
}
impl FeaturesToml {
/// Removes compatibility-only inputs that no longer affect runtime
/// behavior or belong in newly materialized config.
pub fn clear_removed_compatibility_entries(&mut self) {
self.removed_apps_mcp_path_override = None;
self.entries.remove("apps_mcp_path_override");
}
pub fn entries(&self) -> BTreeMap<String, bool> {
let mut entries = self.entries.clone();
if let Some(enabled) = self.code_mode.as_ref().and_then(FeatureToml::enabled) {
@@ -749,58 +742,6 @@ impl FeaturesToml {
}
entries
}
pub fn materialize_resolved_enabled(&mut self, features: &Features) {
self.clear_removed_compatibility_entries();
let Self {
tool_registry: _,
code_mode,
code_mode_host,
non_prefixed_mcp_tool_names,
multi_agent_v2,
token_budget,
rollout_budget,
current_time_reminder,
removed_apps_mcp_path_override: _,
network_proxy,
entries,
} = self;
for key in legacy::legacy_feature_keys() {
entries.remove(key);
}
for spec in FEATURES {
let enabled = features.enabled(spec.id);
if spec.id == Feature::CodeMode {
materialize_resolved_feature_enabled(code_mode, enabled);
} else if spec.id == Feature::CodeModeHost {
materialize_resolved_feature_enabled(code_mode_host, enabled);
} else if spec.id == Feature::NonPrefixedMcpToolNames {
materialize_resolved_feature_enabled(non_prefixed_mcp_tool_names, enabled);
} else if spec.id == Feature::MultiAgentV2 {
materialize_resolved_feature_enabled(multi_agent_v2, enabled);
} else if spec.id == Feature::TokenBudget {
materialize_resolved_feature_enabled(token_budget, enabled);
} else if spec.id == Feature::RolloutBudget {
materialize_resolved_feature_enabled(rollout_budget, enabled);
} else if spec.id == Feature::CurrentTimeReminder {
materialize_resolved_feature_enabled(current_time_reminder, enabled);
} else if spec.id == Feature::NetworkProxy {
materialize_resolved_feature_enabled(network_proxy, enabled);
} else {
entries.insert(spec.key.to_string(), enabled);
}
}
}
}
fn materialize_resolved_feature_enabled<T: FeatureConfig>(
feature: &mut Option<FeatureToml<T>>,
enabled: bool,
) {
match feature {
Some(feature) => feature.set_enabled(enabled),
None => *feature = Some(FeatureToml::Enabled(enabled)),
}
}
impl From<BTreeMap<String, bool>> for FeaturesToml {
@@ -828,20 +769,12 @@ impl<T: FeatureConfig> FeatureToml<T> {
Self::Config(config) => config.enabled(),
}
}
pub fn set_enabled(&mut self, enabled: bool) {
match self {
Self::Enabled(value) => *value = enabled,
Self::Config(config) => config.set_enabled(enabled),
}
}
}
// A trait to be implemented by custom feature config structs when defining a feature that needs more configuration than
// just enabled/disabled.
pub trait FeatureConfig {
fn enabled(&self) -> Option<bool>;
fn set_enabled(&mut self, enabled: bool);
}
/// Single, easy-to-read registry of all feature definitions.
-102
View File
@@ -542,108 +542,6 @@ server_names = ["history", "notes"]
);
}
#[test]
fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config() {
let mut features = Features::with_defaults();
features.enable(Feature::CodeMode);
features.enable(Feature::MultiAgentV2);
features.enable(Feature::NetworkProxy);
features.enable(Feature::NonPrefixedMcpToolNames);
features.enable(Feature::RespectSystemProxy);
let mut features_toml = FeaturesToml {
tool_registry: Some(crate::ToolRegistryConfigToml {
error_on_tool_collisions: Some(true),
turn_metadata_includes_tool_info: Some(true),
}),
code_mode_host: Some(FeatureToml::Config(crate::CodeModeHostConfigToml {
enabled: Some(false),
disable_in_process_fallback: Some(true),
})),
multi_agent_v2: Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml {
enabled: Some(false),
min_wait_timeout_ms: Some(2500),
subagent_developer_instructions: Some("Delegate carefully.".to_string()),
..Default::default()
})),
network_proxy: Some(FeatureToml::Config(crate::NetworkProxyConfigToml {
enabled: Some(false),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
..Default::default()
})),
non_prefixed_mcp_tool_names: Some(FeatureToml::Config(
crate::NonPrefixedMcpToolNamesConfigToml {
enabled: Some(false),
server_names: Some(vec!["history".to_string(), "notes".to_string()]),
},
)),
entries: BTreeMap::new(),
..Default::default()
};
features_toml.materialize_resolved_enabled(&features);
assert_eq!(
features_toml.tool_registry,
Some(crate::ToolRegistryConfigToml {
error_on_tool_collisions: Some(true),
turn_metadata_includes_tool_info: Some(true),
})
);
let entries = features_toml.entries();
assert!(!entries.contains_key("tool_registry"));
for spec in crate::FEATURES {
assert_eq!(
entries.get(spec.key),
Some(&features.enabled(spec.id)),
"{}",
spec.key
);
}
assert_eq!(
features_toml.code_mode_host,
Some(FeatureToml::Config(crate::CodeModeHostConfigToml {
enabled: Some(true),
disable_in_process_fallback: Some(true),
}))
);
assert_eq!(
features_toml.multi_agent_v2,
Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml {
enabled: Some(true),
min_wait_timeout_ms: Some(2500),
subagent_developer_instructions: Some("Delegate carefully.".to_string()),
..Default::default()
}))
);
assert_eq!(
features_toml.network_proxy,
Some(FeatureToml::Config(crate::NetworkProxyConfigToml {
enabled: Some(true),
proxy_url: Some("http://127.0.0.1:43128".to_string()),
..Default::default()
}))
);
assert_eq!(
features_toml.non_prefixed_mcp_tool_names,
Some(FeatureToml::Config(
crate::NonPrefixedMcpToolNamesConfigToml {
enabled: Some(true),
server_names: Some(vec!["history".to_string(), "notes".to_string()]),
}
))
);
let replayed = Features::from_sources(
FeatureConfigSource {
features: Some(&features_toml),
..Default::default()
},
FeatureConfigSource::default(),
FeatureOverrides::default(),
);
assert_eq!(replayed.enabled(Feature::ApplyPatchFreeform), false);
}
#[test]
fn unstable_warning_event_only_mentions_enabled_under_development_features() {
let mut configured_features = Table::new();
@@ -260,10 +260,6 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
memories: MemoriesConfig::default(),
sqlite: SqliteConfig::from_sqlite_home(codex_home.clone()),
log_dir: 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,
codex_home,
history: History::default(),
ephemeral: true,