From 0b04d1b3cc6f57454f094fc5e1be8b3f44d28ee1 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 1 May 2026 17:46:02 +0200 Subject: [PATCH] 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 `.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/.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/.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` --- codex-rs/app-server-client/src/lib.rs | 16 +- codex-rs/config/src/config_toml.rs | 63 +++- codex-rs/config/src/types.rs | 3 +- codex-rs/core/config.schema.json | 61 ++- .../core/src/config/config_loader_tests.rs | 24 ++ codex-rs/core/src/config/config_tests.rs | 87 +++++ codex-rs/core/src/config/mod.rs | 82 +++- codex-rs/core/src/config_lock.rs | 175 +++++++++ codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/config_lock.rs | 355 ++++++++++++++++++ codex-rs/core/src/session/mod.rs | 3 + codex-rs/core/src/session/session.rs | 2 + codex-rs/features/src/feature_configs.rs | 8 + codex-rs/features/src/lib.rs | 39 ++ codex-rs/features/src/tests.rs | 48 +++ codex-rs/thread-manager-sample/src/main.rs | 4 + codex-rs/tui/src/app_server_session.rs | 22 +- 17 files changed, 977 insertions(+), 16 deletions(-) create mode 100644 codex-rs/core/src/config_lock.rs create mode 100644 codex-rs/core/src/session/config_lock.rs diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index cafb696c73..bbbb109eff 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -300,7 +300,15 @@ impl fmt::Display for TypedRequestError { write!(f, "{method} transport error: {source}") } Self::Server { method, source } => { - write!(f, "{method} failed: {}", source.message) + write!( + f, + "{method} failed: {} (code {})", + source.message, source.code + )?; + if let Some(data) = source.data.as_ref() { + write!(f, ", data: {data}")?; + } + Ok(()) } Self::Deserialize { method, source } => { write!(f, "{method} response decode error: {source}") @@ -1915,11 +1923,15 @@ mod tests { method: "thread/read".to_string(), source: JSONRPCErrorError { code: -32603, - data: None, + data: Some(serde_json::json!({"detail": "config lock mismatch"})), message: "internal".to_string(), }, }; assert_eq!(std::error::Error::source(&server).is_some(), false); + assert_eq!( + server.to_string(), + "thread/read failed: internal (code -32603), data: {\"detail\":\"config lock mismatch\"}" + ); let deserialize = TypedRequestError::Deserialize { method: "thread/start".to_string(), diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index cbdc04a604..89eb30b798 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -65,6 +65,28 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [ LMSTUDIO_OSS_PROVIDER_ID, ]; +pub const DEFAULT_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; + +const fn default_allow_login_shell() -> Option { + Some(true) +} + +fn default_history() -> Option { + Some(History::default()) +} + +const fn default_project_doc_max_bytes() -> Option { + Some(DEFAULT_PROJECT_DOC_MAX_BYTES) +} + +fn default_project_doc_fallback_filenames() -> Option> { + Some(Vec::new()) +} + +const fn default_hide_agent_reasoning() -> Option { + Some(false) +} + /// Base config deserialized from ~/.codex/config.toml. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -106,6 +128,7 @@ pub struct ConfigToml { /// If `false`, the model can never use a login shell: `login = true` /// requests are rejected, and omitting `login` defaults to a non-login /// shell. + #[serde(default = "default_allow_login_shell")] pub allow_login_shell: Option, /// Sandbox mode to use. @@ -202,9 +225,11 @@ pub struct ConfigToml { pub model_providers: HashMap, /// Maximum number of bytes to include from an AGENTS.md project doc file. + #[serde(default = "default_project_doc_max_bytes")] pub project_doc_max_bytes: Option, /// Ordered list of fallback filenames to look for when AGENTS.md is missing. + #[serde(default = "default_project_doc_fallback_filenames")] pub project_doc_fallback_filenames: Option>, /// Token budget applied when storing tool/function outputs in the context manager. @@ -233,7 +258,7 @@ pub struct ConfigToml { pub profiles: HashMap, /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. - #[serde(default)] + #[serde(default = "default_history")] pub history: Option, /// Directory where Codex stores the SQLite state DB. @@ -244,6 +269,9 @@ pub struct ConfigToml { /// Defaults to `$CODEX_HOME/log`. pub log_dir: Option, + /// Debugging and reproducibility settings. + pub debug: Option, + /// 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, @@ -253,6 +281,7 @@ pub struct ConfigToml { /// When set to `true`, `AgentReasoning` events will be hidden from the /// UI/output. Defaults to `false`. + #[serde(default = "default_hide_agent_reasoning")] pub hide_agent_reasoning: Option, /// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. @@ -420,6 +449,38 @@ pub struct ConfigToml { pub oss_provider: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ConfigLockfileToml { + pub version: u32, + pub codex_version: String, + + /// 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, +} + +#[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, + + /// Lockfile to replay as the authoritative effective config. + pub load_path: Option, + + /// Allow replaying a lock generated by a different Codex version. + pub allow_codex_version_mismatch: Option, + + /// Save fields resolved from the model catalog/session configuration. + pub save_fields_resolved_from_model_catalog: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ThreadStoreToml { diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 91925fbeb4..b856367a66 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -136,6 +136,7 @@ impl UriBasedFileOpener { /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[serde(default)] #[schemars(deny_unknown_fields)] pub struct History { /// If true, history entries will not be written to disk. @@ -262,7 +263,7 @@ pub struct MemoriesToml { } /// Effective memories settings after defaults are applied. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct MemoriesConfig { pub disable_on_external_context: bool, pub generate_memories: bool, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index c8397418da..43168d8378 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -693,6 +693,45 @@ }, "type": "object" }, + "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" + }, "ExternalConfigMigrationPrompts": { "additionalProperties": false, "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", @@ -853,6 +892,7 @@ "description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`.", "properties": { "max_bytes": { + "default": null, "description": "If set, the maximum size of the history file in bytes. The oldest entries are dropped once the file exceeds this limit.", "format": "uint", "minimum": 0.0, @@ -864,12 +904,10 @@ "$ref": "#/definitions/HistoryPersistence" } ], + "default": "save-all", "description": "If true, history entries will not be written to disk." } }, - "required": [ - "persistence" - ], "type": "object" }, "HistoryPersistence": { @@ -3629,6 +3667,7 @@ "description": "Agent-related settings (thread limits, etc.)." }, "allow_login_shell": { + "default": true, "description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.", "type": "boolean" }, @@ -3714,6 +3753,14 @@ "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" @@ -4060,6 +4107,7 @@ "description": "Compatibility-only settings retained so legacy `ghost_snapshot` config still loads." }, "hide_agent_reasoning": { + "default": false, "description": "When set to `true`, `AgentReasoning` events will be hidden from the UI/output. Defaults to `false`.", "type": "boolean" }, @@ -4069,7 +4117,10 @@ "$ref": "#/definitions/History" } ], - "default": null, + "default": { + "max_bytes": null, + "persistence": "save-all" + }, "description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`." }, "hooks": { @@ -4280,6 +4331,7 @@ "type": "object" }, "project_doc_fallback_filenames": { + "default": [], "description": "Ordered list of fallback filenames to look for when AGENTS.md is missing.", "items": { "type": "string" @@ -4287,6 +4339,7 @@ "type": "array" }, "project_doc_max_bytes": { + "default": 32768, "description": "Maximum number of bytes to include from an AGENTS.md project doc file.", "format": "uint", "minimum": 0.0, diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 1f6e145cd1..6fcd5f872d 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -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()?; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index aeee21cf70..1352de991e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -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()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 83b8d78b8b..b30655bff9 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -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 = 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, + + /// 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>, + /// 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(), diff --git a/codex-rs/core/src/config_lock.rs b/codex-rs/core/src/config_lock.rs new file mode 100644 index 0000000000..ff8f1e761d --- /dev/null +++ b/codex-rs/core/src/config_lock.rs @@ -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 { + 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 { + 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) -> io::Error { + io::Error::other(message.into()) +} + +fn compact_diff(root: &str, expected: &T, actual: &T) -> io::Result { + 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(value: &T, label: &str) -> io::Result { + toml::Value::try_from(value) + .map_err(|err| config_lock_error(format!("failed to serialize {label}: {err}"))) +} + +pub(crate) fn toml_round_trip(value: &impl Serialize, label: &'static str) -> io::Result +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) +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 6a61079a3b..a396851f98 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -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; diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs new file mode 100644 index 0000000000..d1f190510a --- /dev/null +++ b/codex-rs/core/src/session/config_lock.rs @@ -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 { + Ok(config_lockfile(session_configuration_to_lock_config_toml( + self, + )?)) + } +} + +fn session_configuration_to_lock_config_toml( + sc: &SessionConfiguration, +) -> anyhow::Result { + 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::( + &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( + value: &impl serde::Serialize, + label: &'static str, +) -> anyhow::Result +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"); + } +} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 577852cc66..c45a8b638a 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -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; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 2c08caff48..dc439d6a5e 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -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 diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 21c504bd8d..4f3eb5b11c 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -30,6 +30,10 @@ impl FeatureConfig for MultiAgentV2ConfigToml { fn enabled(&self) -> Option { self.enabled } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] @@ -45,4 +49,8 @@ impl FeatureConfig for AppsMcpPathOverrideConfigToml { fn enabled(&self) -> Option { self.enabled.or(self.path.as_ref().map(|_| true)) } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } } diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 04c3f4921d..bf384672a1 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -593,6 +593,37 @@ impl FeaturesToml { } entries } + + pub fn materialize_resolved_enabled(&mut self, features: &Features) { + let Self { + multi_agent_v2, + apps_mcp_path_override, + 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::MultiAgentV2 { + materialize_resolved_feature_enabled(multi_agent_v2, enabled); + } else if spec.id == Feature::AppsMcpPathOverride { + materialize_resolved_feature_enabled(apps_mcp_path_override, enabled); + } else { + entries.insert(spec.key.to_string(), enabled); + } + } + } +} + +fn materialize_resolved_feature_enabled( + feature: &mut Option>, + enabled: bool, +) { + match feature { + Some(feature) => feature.set_enabled(enabled), + None => *feature = Some(FeatureToml::Enabled(enabled)), + } } impl From> for FeaturesToml { @@ -620,12 +651,20 @@ impl FeatureToml { 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; + fn set_enabled(&mut self, enabled: bool); } /// Single, easy-to-read registry of all feature definitions. diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index cb6310e089..6235c1c3e5 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -490,6 +490,54 @@ usage_hint_enabled = false ); } +#[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.disable(Feature::ToolSearch); + + let mut features_toml = FeaturesToml { + multi_agent_v2: Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml { + enabled: Some(false), + min_wait_timeout_ms: Some(2500), + ..Default::default() + })), + entries: BTreeMap::from([("include_apply_patch_tool".to_string(), true)]), + ..Default::default() + }; + + features_toml.materialize_resolved_enabled(&features); + + let entries = features_toml.entries(); + assert_eq!(entries.get("include_apply_patch_tool"), None); + for spec in crate::FEATURES { + assert_eq!( + entries.get(spec.key), + Some(&features.enabled(spec.id)), + "{}", + spec.key + ); + } + assert_eq!( + features_toml.multi_agent_v2, + Some(FeatureToml::Config(crate::MultiAgentV2ConfigToml { + enabled: Some(true), + min_wait_timeout_ms: Some(2500), + ..Default::default() + })) + ); + 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(); diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 757f79bfa9..cc2262512d 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -215,6 +215,10 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R memories: MemoriesConfig::default(), sqlite_home: codex_home.to_path_buf(), 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, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index c698a76dcc..449da8e212 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -120,6 +120,10 @@ use color_eyre::eyre::WrapErr; use std::collections::HashMap; use std::path::PathBuf; +fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { + color_eyre::eyre::eyre!("{context}: {err}") +} + /// Data collected during the TUI bootstrap phase that the main event loop /// needs to configure the UI, telemetry, and initial rate-limit prefetch. /// @@ -203,7 +207,9 @@ impl AppServerSession { }, }) .await - .wrap_err("model/list failed during TUI bootstrap")?; + .map_err(|err| { + bootstrap_request_error("model/list failed during TUI bootstrap", err) + })?; let available_models = models .data .into_iter() @@ -287,7 +293,7 @@ impl AppServerSession { }, }) .await - .wrap_err("account/read failed during TUI bootstrap") + .map_err(|err| bootstrap_request_error("account/read failed during TUI bootstrap", err)) } pub(crate) async fn external_agent_config_detect( @@ -342,7 +348,9 @@ impl AppServerSession { ), }) .await - .wrap_err("thread/start failed during TUI bootstrap")?; + .map_err(|err| { + bootstrap_request_error("thread/start failed during TUI bootstrap", err) + })?; started_thread_from_start_response(response, config, self.thread_params_mode()).await } @@ -364,7 +372,9 @@ impl AppServerSession { ), }) .await - .wrap_err("thread/resume failed during TUI bootstrap")?; + .map_err(|err| { + bootstrap_request_error("thread/resume failed during TUI bootstrap", err) + })?; let fork_parent_title = self .fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref()) .await; @@ -393,7 +403,9 @@ impl AppServerSession { ), }) .await - .wrap_err("thread/fork failed during TUI bootstrap")?; + .map_err(|err| { + bootstrap_request_error("thread/fork failed during TUI bootstrap", err) + })?; let fork_parent_title = self .fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref()) .await;