mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: export and replay effective config locks (#20405)
## Why For reproducibility. A hand-written `config.toml` is not enough to recreate what a Codex session actually ran with because layered config, CLI overrides, defaults, feature aliases, resolved feature config, prompt setup, and model-catalog/session values can all affect the final runtime behavior. This PR adds an effective config lockfile path: one run can export the resolved session config, and a later run can replay that lockfile and fail early if the regenerated effective config drifts. ## What Changed - Add a dedicated `ConfigLockfileToml` wrapper with top-level lockfile metadata plus the replayable config: ```toml version = 1 codex_version = "..." [config] # effective ConfigToml fields ``` - Keep lockfile metadata out of regular `ConfigToml`; replay loads `ConfigLockfileToml` and then uses its nested `config` as the authoritative config layer. - Add `debug.config_lockfile.export_dir` to write `<thread_id>.config.lock.toml` when a root session starts. - Add `debug.config_lockfile.load_path` to replay a saved lockfile and validate the regenerated session lockfile against it. - Add `debug.config_lockfile.allow_codex_version_mismatch` to optionally tolerate Codex binary version drift while still comparing the rest of the lockfile. - Add `debug.config_lockfile.save_fields_resolved_from_model_catalog` so lock creation can either save model-catalog/session-resolved fields or intentionally leave those fields dynamic. - Build lockfiles from the effective config plus resolved runtime values such as model selection, reasoning settings, prompts, service tier, web search mode, feature states/config, memories config, skill instructions, and agent limits. - Materialize feature aliases and custom feature config into the lockfile so replay compares canonical resolved behavior instead of user-authored alias shape. - Strip profile/debug/file-include/environment-specific inputs from generated lockfiles so they contain replayable values rather than the inputs that produced those values. - Surface JSON-RPC server error code/data in app-server client and TUI bootstrap errors so config-lock replay failures include the actual TOML diff. - Regenerate the config schema for the new debug config keys. ## Review Notes The main flow is split across these files: - `config/src/config_toml.rs`: lockfile/debug TOML shapes. - `core/src/config/mod.rs`: loading `debug.config_lockfile.*`, replaying a lockfile as a config layer, and preserving the expected lockfile for validation. - `core/src/session/config_lock.rs`: exporting the current session lockfile and materializing resolved session/config values. - `core/src/config_lock.rs`: lockfile parsing, metadata/version checks, replay comparison, and diff formatting. ## Usage Export a lockfile from a normal session: ```sh codex -c 'debug.config_lockfile.export_dir="/tmp/codex-locks"' ``` Export a lockfile without saving model-catalog/session-resolved fields: ```sh codex -c 'debug.config_lockfile.export_dir="/tmp/codex-locks"' \ -c 'debug.config_lockfile.save_fields_resolved_from_model_catalog=false' ``` Replay a saved lockfile in a later session: ```sh codex -c 'debug.config_lockfile.load_path="/tmp/codex-locks/<thread_id>.config.lock.toml"' ``` If replay resolves to a different effective config, startup fails with a TOML diff. To tolerate Codex binary version drift during replay: ```sh codex -c 'debug.config_lockfile.load_path="/tmp/codex-locks/<thread_id>.config.lock.toml"' \ -c 'debug.config_lockfile.allow_codex_version_mismatch=true' ``` ## Limitations This does not support custom rules/network policies. ## Verification - `cargo test -p codex-core config_lock` - `cargo test -p codex-config` - `cargo test -p codex-thread-manager-sample`
This commit is contained in:
committed by
GitHub
Unverified
parent
ff27d01676
commit
0b04d1b3cc
@@ -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(),
|
||||
|
||||
@@ -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<bool> {
|
||||
Some(true)
|
||||
}
|
||||
|
||||
fn default_history() -> Option<History> {
|
||||
Some(History::default())
|
||||
}
|
||||
|
||||
const fn default_project_doc_max_bytes() -> Option<usize> {
|
||||
Some(DEFAULT_PROJECT_DOC_MAX_BYTES)
|
||||
}
|
||||
|
||||
fn default_project_doc_fallback_filenames() -> Option<Vec<String>> {
|
||||
Some(Vec::new())
|
||||
}
|
||||
|
||||
const fn default_hide_agent_reasoning() -> Option<bool> {
|
||||
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<bool>,
|
||||
|
||||
/// Sandbox mode to use.
|
||||
@@ -202,9 +225,11 @@ pub struct ConfigToml {
|
||||
pub model_providers: HashMap<String, ModelProviderInfo>,
|
||||
|
||||
/// 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<usize>,
|
||||
|
||||
/// 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<Vec<String>>,
|
||||
|
||||
/// Token budget applied when storing tool/function outputs in the context manager.
|
||||
@@ -233,7 +258,7 @@ pub struct ConfigToml {
|
||||
pub profiles: HashMap<String, ConfigProfile>,
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_history")]
|
||||
pub history: Option<History>,
|
||||
|
||||
/// Directory where Codex stores the SQLite state DB.
|
||||
@@ -244,6 +269,9 @@ 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>,
|
||||
@@ -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<bool>,
|
||||
|
||||
/// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output.
|
||||
@@ -420,6 +449,38 @@ 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,
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -23,7 +23,9 @@ use codex_config::ResidencyRequirement;
|
||||
use codex_config::SandboxModeRequirement;
|
||||
use codex_config::Sourced;
|
||||
use codex_config::ThreadConfigLoader;
|
||||
use codex_config::config_toml::ConfigLockfileToml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::config_toml::DEFAULT_PROJECT_DOC_MAX_BYTES;
|
||||
use codex_config::config_toml::ProjectConfig;
|
||||
use codex_config::config_toml::RealtimeAudioConfig;
|
||||
use codex_config::config_toml::RealtimeConfig;
|
||||
@@ -100,6 +102,7 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -115,6 +118,9 @@ use crate::config::permissions::default_builtin_permission_profile_name;
|
||||
use crate::config::permissions::get_readable_roots_required_for_codex_runtime;
|
||||
use crate::config::permissions::network_proxy_config_for_profile_selection;
|
||||
use crate::config::permissions::validate_user_permission_profile_names;
|
||||
use crate::config_lock::config_without_lock_controls;
|
||||
use crate::config_lock::lock_layer_from_config;
|
||||
use crate::config_lock::read_config_lock_from_path;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use toml::Value as TomlValue;
|
||||
use toml_edit::DocumentMut;
|
||||
@@ -162,7 +168,7 @@ impl Default for GhostSnapshotConfig {
|
||||
/// Maximum number of bytes of the documentation that will be embedded. Larger
|
||||
/// files are *silently truncated* to this size so we do not take up too much of
|
||||
/// the context window.
|
||||
pub(crate) const AGENTS_MD_MAX_BYTES: usize = 32 * 1024; // 32 KiB
|
||||
pub(crate) const AGENTS_MD_MAX_BYTES: usize = DEFAULT_PROJECT_DOC_MAX_BYTES; // 32 KiB
|
||||
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = Some(6);
|
||||
pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION: usize = 4;
|
||||
pub(crate) const DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
|
||||
@@ -623,6 +629,20 @@ pub struct Config {
|
||||
/// Directory where Codex writes log files (defaults to `$CODEX_HOME/log`).
|
||||
pub log_dir: PathBuf,
|
||||
|
||||
/// Directory where Codex writes effective session config lock files.
|
||||
pub config_lock_export_dir: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Whether config lock replay ignores Codex version drift between the
|
||||
/// lock metadata and the regenerated lock.
|
||||
pub config_lock_allow_codex_version_mismatch: bool,
|
||||
|
||||
/// Whether config lock creation saves values resolved from the model
|
||||
/// catalog/session configuration.
|
||||
pub config_lock_save_fields_resolved_from_model_catalog: bool,
|
||||
|
||||
/// Effective config lock used for strict replay validation.
|
||||
pub config_lock_toml: Option<Arc<ConfigLockfileToml>>,
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
pub history: History,
|
||||
|
||||
@@ -792,7 +812,7 @@ pub struct Config {
|
||||
pub otel: codex_config::types::OtelConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct MultiAgentV2Config {
|
||||
pub max_concurrent_threads_per_session: usize,
|
||||
pub min_wait_timeout_ms: i64,
|
||||
@@ -961,6 +981,42 @@ impl ConfigBuilder {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, err));
|
||||
}
|
||||
};
|
||||
let config_lock_settings = config_toml
|
||||
.debug
|
||||
.as_ref()
|
||||
.and_then(|debug| debug.config_lockfile.as_ref());
|
||||
if let Some(config_lock_load_path) =
|
||||
config_lock_settings.and_then(|config_lock| config_lock.load_path.as_ref())
|
||||
{
|
||||
let allow_codex_version_mismatch = config_lock_settings
|
||||
.and_then(|config_lock| config_lock.allow_codex_version_mismatch)
|
||||
.unwrap_or(false);
|
||||
let save_fields_resolved_from_model_catalog = config_lock_settings
|
||||
.and_then(|config_lock| config_lock.save_fields_resolved_from_model_catalog)
|
||||
.unwrap_or(true);
|
||||
let lockfile_toml = read_config_lock_from_path(config_lock_load_path).await?;
|
||||
let expected_lock_config = lockfile_toml.clone();
|
||||
let lock_layer = lock_layer_from_config(config_lock_load_path, &lockfile_toml)?;
|
||||
let lock_config_toml = config_without_lock_controls(&lockfile_toml.config);
|
||||
let lock_config_layer_stack = ConfigLayerStack::new(
|
||||
vec![lock_layer],
|
||||
config_layer_stack.requirements().clone(),
|
||||
config_layer_stack.requirements_toml().clone(),
|
||||
)?;
|
||||
let mut config = Config::load_config_with_layer_stack(
|
||||
LOCAL_FS.as_ref(),
|
||||
lock_config_toml,
|
||||
harness_overrides,
|
||||
codex_home,
|
||||
lock_config_layer_stack,
|
||||
)
|
||||
.await?;
|
||||
config.config_lock_toml = Some(Arc::new(expected_lock_config));
|
||||
config.config_lock_allow_codex_version_mismatch = allow_codex_version_mismatch;
|
||||
config.config_lock_save_fields_resolved_from_model_catalog =
|
||||
save_fields_resolved_from_model_catalog;
|
||||
return Ok(config);
|
||||
}
|
||||
Config::load_config_with_layer_stack(
|
||||
LOCAL_FS.as_ref(),
|
||||
config_toml,
|
||||
@@ -2630,7 +2686,9 @@ impl Config {
|
||||
"model instructions file",
|
||||
)
|
||||
.await?;
|
||||
let base_instructions = base_instructions.or(file_base_instructions);
|
||||
let base_instructions = base_instructions
|
||||
.or(file_base_instructions)
|
||||
.or(cfg.instructions.clone());
|
||||
let developer_instructions = developer_instructions.or(cfg.developer_instructions);
|
||||
let include_permissions_instructions = config_profile
|
||||
.include_permissions_instructions
|
||||
@@ -2902,6 +2960,24 @@ impl Config {
|
||||
codex_home,
|
||||
sqlite_home,
|
||||
log_dir,
|
||||
config_lock_export_dir: cfg
|
||||
.debug
|
||||
.as_ref()
|
||||
.and_then(|debug| debug.config_lockfile.as_ref())
|
||||
.and_then(|config_lock| config_lock.export_dir.clone()),
|
||||
config_lock_allow_codex_version_mismatch: cfg
|
||||
.debug
|
||||
.as_ref()
|
||||
.and_then(|debug| debug.config_lockfile.as_ref())
|
||||
.and_then(|config_lock| config_lock.allow_codex_version_mismatch)
|
||||
.unwrap_or(false),
|
||||
config_lock_save_fields_resolved_from_model_catalog: cfg
|
||||
.debug
|
||||
.as_ref()
|
||||
.and_then(|debug| debug.config_lockfile.as_ref())
|
||||
.and_then(|config_lock| config_lock.save_fields_resolved_from_model_catalog)
|
||||
.unwrap_or(true),
|
||||
config_lock_toml: None,
|
||||
config_layer_stack,
|
||||
history,
|
||||
ephemeral: ephemeral.unwrap_or_default(),
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::io;
|
||||
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerSource;
|
||||
use codex_config::config_toml::ConfigLockfileToml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use similar::TextDiff;
|
||||
|
||||
pub(crate) const CONFIG_LOCK_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ConfigLockReplayOptions {
|
||||
pub allow_codex_version_mismatch: bool,
|
||||
}
|
||||
|
||||
pub(crate) async fn read_config_lock_from_path(
|
||||
path: &AbsolutePathBuf,
|
||||
) -> io::Result<ConfigLockfileToml> {
|
||||
let contents = tokio::fs::read_to_string(path).await.map_err(|err| {
|
||||
config_lock_error(format!(
|
||||
"failed to read config lock file {}: {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let lockfile: ConfigLockfileToml = toml::from_str(&contents).map_err(|err| {
|
||||
config_lock_error(format!(
|
||||
"failed to parse config lock file {}: {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
validate_config_lock_metadata_shape(&lockfile)?;
|
||||
Ok(lockfile)
|
||||
}
|
||||
|
||||
pub(crate) fn config_lockfile(config: ConfigToml) -> ConfigLockfileToml {
|
||||
ConfigLockfileToml {
|
||||
version: CONFIG_LOCK_VERSION,
|
||||
codex_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_config_lock_replay(
|
||||
expected_lock: &ConfigLockfileToml,
|
||||
actual_lock: &ConfigLockfileToml,
|
||||
options: ConfigLockReplayOptions,
|
||||
) -> io::Result<()> {
|
||||
validate_config_lock_metadata_shape(expected_lock)?;
|
||||
validate_config_lock_metadata_shape(actual_lock)?;
|
||||
|
||||
if !options.allow_codex_version_mismatch
|
||||
&& expected_lock.codex_version != actual_lock.codex_version
|
||||
{
|
||||
return Err(config_lock_error(format!(
|
||||
"config lock Codex version mismatch: lock was generated by {}, current version is {}; set debug.config_lockfile.allow_codex_version_mismatch=true to ignore this",
|
||||
expected_lock.codex_version, actual_lock.codex_version
|
||||
)));
|
||||
}
|
||||
|
||||
let expected_lock = config_lock_for_comparison(expected_lock, options);
|
||||
let actual_lock = config_lock_for_comparison(actual_lock, options);
|
||||
if expected_lock != actual_lock {
|
||||
let diff = compact_diff("config", &expected_lock, &actual_lock)
|
||||
.unwrap_or_else(|err| format!("failed to build config lock diff: {err}"));
|
||||
return Err(config_lock_error(format!(
|
||||
"replayed effective config does not match config lock: {diff}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn lock_layer_from_config(
|
||||
lock_path: &AbsolutePathBuf,
|
||||
lockfile: &ConfigLockfileToml,
|
||||
) -> io::Result<ConfigLayerEntry> {
|
||||
let value = toml_value(
|
||||
&config_without_lock_controls(&lockfile.config),
|
||||
"config lock",
|
||||
)?;
|
||||
Ok(ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: lock_path.clone(),
|
||||
},
|
||||
value,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn config_without_lock_controls(config: &ConfigToml) -> ConfigToml {
|
||||
let mut config = config.clone();
|
||||
clear_config_lock_debug_controls(&mut config);
|
||||
config
|
||||
}
|
||||
|
||||
pub(crate) fn clear_config_lock_debug_controls(config: &mut ConfigToml) {
|
||||
if let Some(debug) = config.debug.as_mut() {
|
||||
debug.config_lockfile = None;
|
||||
}
|
||||
if config
|
||||
.debug
|
||||
.as_ref()
|
||||
.is_some_and(|debug| debug.config_lockfile.is_none())
|
||||
{
|
||||
config.debug = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_config_lock_metadata_shape(lock: &ConfigLockfileToml) -> io::Result<()> {
|
||||
if lock.version != CONFIG_LOCK_VERSION {
|
||||
return Err(config_lock_error(format!(
|
||||
"unsupported config lock version {}; expected {CONFIG_LOCK_VERSION}",
|
||||
lock.version
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_lock_for_comparison(
|
||||
lockfile: &ConfigLockfileToml,
|
||||
options: ConfigLockReplayOptions,
|
||||
) -> ConfigLockfileToml {
|
||||
let mut lockfile = lockfile.clone();
|
||||
clear_config_lock_debug_controls(&mut lockfile.config);
|
||||
if options.allow_codex_version_mismatch {
|
||||
lockfile.codex_version.clear();
|
||||
}
|
||||
lockfile
|
||||
}
|
||||
|
||||
fn config_lock_error(message: impl Into<String>) -> io::Error {
|
||||
io::Error::other(message.into())
|
||||
}
|
||||
|
||||
fn compact_diff<T: Serialize>(root: &str, expected: &T, actual: &T) -> io::Result<String> {
|
||||
let expected = toml::to_string_pretty(expected).map_err(|err| {
|
||||
config_lock_error(format!(
|
||||
"failed to serialize expected {root} lock TOML: {err}"
|
||||
))
|
||||
})?;
|
||||
let actual = toml::to_string_pretty(actual).map_err(|err| {
|
||||
config_lock_error(format!(
|
||||
"failed to serialize actual {root} lock TOML: {err}"
|
||||
))
|
||||
})?;
|
||||
Ok(TextDiff::from_lines(&expected, &actual)
|
||||
.unified_diff()
|
||||
.context_radius(2)
|
||||
.header("expected", "actual")
|
||||
.to_string())
|
||||
}
|
||||
|
||||
fn toml_value<T: Serialize>(value: &T, label: &str) -> io::Result<toml::Value> {
|
||||
toml::Value::try_from(value)
|
||||
.map_err(|err| config_lock_error(format!("failed to serialize {label}: {err}")))
|
||||
}
|
||||
|
||||
pub(crate) fn toml_round_trip<T>(value: &impl Serialize, label: &'static str) -> io::Result<T>
|
||||
where
|
||||
T: DeserializeOwned + Serialize,
|
||||
{
|
||||
let value = toml_value(value, label)?;
|
||||
let toml = value.clone().try_into().map_err(|err| {
|
||||
config_lock_error(format!("failed to convert {label} to TOML shape: {err}"))
|
||||
})?;
|
||||
let represented_value = toml_value(&toml, label)?;
|
||||
if represented_value != value {
|
||||
return Err(config_lock_error(format!(
|
||||
"resolved {label} cannot be fully represented as TOML"
|
||||
)));
|
||||
}
|
||||
Ok(toml)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
use anyhow::Context;
|
||||
use codex_config::config_toml::ConfigLockfileToml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::types::MemoriesToml;
|
||||
use codex_features::AppsMcpPathOverrideConfigToml;
|
||||
use codex_features::Feature;
|
||||
use codex_features::FeatureToml;
|
||||
use codex_features::FeaturesToml;
|
||||
use codex_features::MultiAgentV2ConfigToml;
|
||||
use codex_protocol::ThreadId;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config_lock::ConfigLockReplayOptions;
|
||||
use crate::config_lock::clear_config_lock_debug_controls;
|
||||
use crate::config_lock::config_lockfile;
|
||||
use crate::config_lock::toml_round_trip;
|
||||
use crate::config_lock::validate_config_lock_replay;
|
||||
|
||||
use super::SessionConfiguration;
|
||||
|
||||
pub(crate) async fn validate_config_lock_if_configured(
|
||||
session_configuration: &SessionConfiguration,
|
||||
) -> anyhow::Result<()> {
|
||||
if session_configuration.session_source.is_non_root_agent() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(expected) = session_configuration
|
||||
.original_config_do_not_use
|
||||
.config_lock_toml
|
||||
.as_ref()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let actual = session_configuration.to_config_lockfile_toml()?;
|
||||
let config = session_configuration.original_config_do_not_use.as_ref();
|
||||
let options = ConfigLockReplayOptions {
|
||||
allow_codex_version_mismatch: config.config_lock_allow_codex_version_mismatch,
|
||||
};
|
||||
validate_config_lock_replay(expected, &actual, options)
|
||||
.context("config lock replay validation failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn export_config_lock_if_configured(
|
||||
session_configuration: &SessionConfiguration,
|
||||
conversation_id: ThreadId,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = session_configuration.original_config_do_not_use.as_ref();
|
||||
let Some(export_dir) = config.config_lock_export_dir.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let lock = session_configuration.to_config_lockfile_toml()?;
|
||||
let lock = toml::to_string_pretty(&lock).context("failed to serialize config lock")?;
|
||||
let path = export_dir.join(format!("{conversation_id}.config.lock.toml"));
|
||||
|
||||
tokio::fs::create_dir_all(export_dir)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create config lock export directory {}",
|
||||
export_dir.display()
|
||||
)
|
||||
})?;
|
||||
tokio::fs::write(&path, lock)
|
||||
.await
|
||||
.with_context(|| format!("failed to write config lock to {}", path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl SessionConfiguration {
|
||||
pub(crate) fn to_config_lockfile_toml(&self) -> anyhow::Result<ConfigLockfileToml> {
|
||||
Ok(config_lockfile(session_configuration_to_lock_config_toml(
|
||||
self,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn session_configuration_to_lock_config_toml(
|
||||
sc: &SessionConfiguration,
|
||||
) -> anyhow::Result<ConfigToml> {
|
||||
let config = sc.original_config_do_not_use.as_ref();
|
||||
// Start from the resolved layer stack, then patch in values that are only
|
||||
// known after session setup. Export and replay validation both use this
|
||||
// path, so every field here is part of the lockfile contract.
|
||||
let mut lock_config: ConfigToml = config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
.try_into()
|
||||
.context("failed to deserialize effective config for config lock")?;
|
||||
|
||||
if config.config_lock_save_fields_resolved_from_model_catalog {
|
||||
save_session_resolved_fields(sc, &mut lock_config);
|
||||
}
|
||||
|
||||
save_config_resolved_fields(config, &mut lock_config)?;
|
||||
drop_lockfile_inputs(&mut lock_config);
|
||||
|
||||
Ok(lock_config)
|
||||
}
|
||||
|
||||
/// Saves values chosen during session construction from the model catalog,
|
||||
/// collaboration mode, and resolved prompt setup.
|
||||
///
|
||||
/// These values are not always present in the raw layer stack, so copy them
|
||||
/// from the live session when the lockfile should be fully self-contained.
|
||||
fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut ConfigToml) {
|
||||
lock_config.model = Some(sc.collaboration_mode.model().to_string());
|
||||
lock_config.model_reasoning_effort = sc.collaboration_mode.reasoning_effort();
|
||||
lock_config.model_reasoning_summary = sc.model_reasoning_summary;
|
||||
lock_config.service_tier = sc.service_tier;
|
||||
lock_config.instructions = Some(sc.base_instructions.clone());
|
||||
lock_config.developer_instructions = sc.developer_instructions.clone();
|
||||
lock_config.compact_prompt = sc.compact_prompt.clone();
|
||||
lock_config.personality = sc.personality;
|
||||
lock_config.approval_policy = Some(sc.approval_policy.value());
|
||||
lock_config.approvals_reviewer = Some(sc.approvals_reviewer);
|
||||
}
|
||||
|
||||
/// Saves values stored on `Config` after higher-level resolution,
|
||||
/// normalization, defaulting, or feature materialization.
|
||||
///
|
||||
/// Persist the resolved representation so replay compares against the behavior
|
||||
/// Codex actually ran with, not only the user-authored TOML inputs.
|
||||
fn save_config_resolved_fields(
|
||||
config: &Config,
|
||||
lock_config: &mut ConfigToml,
|
||||
) -> anyhow::Result<()> {
|
||||
lock_config.web_search = Some(config.web_search_mode.value());
|
||||
lock_config.model_provider = Some(config.model_provider_id.clone());
|
||||
lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort;
|
||||
lock_config.model_verbosity = config.model_verbosity;
|
||||
lock_config.include_permissions_instructions = Some(config.include_permissions_instructions);
|
||||
lock_config.include_apps_instructions = Some(config.include_apps_instructions);
|
||||
lock_config.include_environment_context = Some(config.include_environment_context);
|
||||
lock_config.background_terminal_max_timeout = Some(config.background_terminal_max_timeout);
|
||||
|
||||
// Feature aliases and feature configs need to be written in their resolved
|
||||
// form; otherwise replay can drift when a legacy key maps to the same
|
||||
// runtime feature.
|
||||
let features = lock_config
|
||||
.features
|
||||
.get_or_insert_with(FeaturesToml::default);
|
||||
features.materialize_resolved_enabled(config.features.get());
|
||||
let mut multi_agent_v2: MultiAgentV2ConfigToml =
|
||||
resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?;
|
||||
multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2));
|
||||
features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2));
|
||||
features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml {
|
||||
enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)),
|
||||
path: config.apps_mcp_path_override.clone(),
|
||||
}));
|
||||
lock_config.memories = Some(resolved_config_to_toml::<MemoriesToml>(
|
||||
&config.memories,
|
||||
"memories",
|
||||
)?);
|
||||
|
||||
let agents = lock_config.agents.get_or_insert_with(Default::default);
|
||||
// Multi-agent v2 owns thread fanout through its feature config. Preserve
|
||||
// the legacy agents.max_threads setting only when v2 is disabled.
|
||||
agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) {
|
||||
None
|
||||
} else {
|
||||
config.agent_max_threads
|
||||
};
|
||||
agents.max_depth = Some(config.agent_max_depth);
|
||||
agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds;
|
||||
agents.interrupt_message = Some(config.agent_interrupt_message_enabled);
|
||||
|
||||
lock_config
|
||||
.skills
|
||||
.get_or_insert_with(Default::default)
|
||||
.include_instructions = Some(config.include_skill_instructions);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drop_lockfile_inputs(lock_config: &mut ConfigToml) {
|
||||
// The lockfile should contain replayable values, not the profile,
|
||||
// debug-control, file-include, and environment-specific inputs that
|
||||
// produced those values in the original session.
|
||||
lock_config.profile = None;
|
||||
lock_config.profiles.clear();
|
||||
clear_config_lock_debug_controls(lock_config);
|
||||
lock_config.model_instructions_file = None;
|
||||
lock_config.experimental_instructions_file = None;
|
||||
lock_config.experimental_compact_prompt_file = None;
|
||||
lock_config.model_catalog_json = None;
|
||||
lock_config.sandbox_mode = None;
|
||||
lock_config.sandbox_workspace_write = None;
|
||||
lock_config.default_permissions = None;
|
||||
lock_config.permissions = None;
|
||||
lock_config.experimental_use_unified_exec_tool = None;
|
||||
lock_config.experimental_use_freeform_apply_patch = None;
|
||||
}
|
||||
|
||||
fn resolved_config_to_toml<Toml>(
|
||||
value: &impl serde::Serialize,
|
||||
label: &'static str,
|
||||
) -> anyhow::Result<Toml>
|
||||
where
|
||||
Toml: serde::de::DeserializeOwned + serde::Serialize,
|
||||
{
|
||||
toml_round_trip(value, label).map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_contains_prompts_and_materializes_features() {
|
||||
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
sc.base_instructions = "resolved instructions".to_string();
|
||||
sc.developer_instructions = Some("resolved developer instructions".to_string());
|
||||
sc.compact_prompt = Some("resolved compact prompt".to_string());
|
||||
|
||||
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
let lock = &lockfile.config;
|
||||
|
||||
assert_eq!(lock.instructions, Some(sc.base_instructions.clone()));
|
||||
assert_eq!(lock.developer_instructions, sc.developer_instructions);
|
||||
assert_eq!(lock.compact_prompt, sc.compact_prompt);
|
||||
assert_eq!(lock.model, Some(sc.collaboration_mode.model().to_string()));
|
||||
assert_eq!(
|
||||
lock.model_reasoning_effort,
|
||||
sc.collaboration_mode.reasoning_effort()
|
||||
);
|
||||
assert_eq!(lock.profile, None);
|
||||
assert!(lock.profiles.is_empty());
|
||||
assert!(
|
||||
lock.debug
|
||||
.as_ref()
|
||||
.is_none_or(|debug| debug.config_lockfile.is_none())
|
||||
);
|
||||
assert!(lock.memories.is_some());
|
||||
|
||||
let features = lock
|
||||
.features
|
||||
.as_ref()
|
||||
.expect("lock should materialize feature states");
|
||||
let feature_entries = features.entries();
|
||||
for spec in codex_features::FEATURES {
|
||||
assert_eq!(
|
||||
feature_entries.get(spec.key),
|
||||
Some(&sc.original_config_do_not_use.features.enabled(spec.id)),
|
||||
"{}",
|
||||
spec.key
|
||||
);
|
||||
}
|
||||
|
||||
let multi_agent_v2 = features
|
||||
.multi_agent_v2
|
||||
.as_ref()
|
||||
.expect("multi_agent_v2 config should be materialized");
|
||||
assert!(matches!(
|
||||
multi_agent_v2,
|
||||
FeatureToml::Config(MultiAgentV2ConfigToml {
|
||||
enabled: Some(false),
|
||||
max_concurrent_threads_per_session: Some(_),
|
||||
min_wait_timeout_ms: Some(_),
|
||||
usage_hint_enabled: Some(_),
|
||||
hide_spawn_agent_metadata: Some(_),
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
assert_eq!(lockfile.version, crate::config_lock::CONFIG_LOCK_VERSION);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_skips_session_values_when_model_catalog_fields_are_not_saved() {
|
||||
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let mut config = (*sc.original_config_do_not_use).clone();
|
||||
config.config_lock_save_fields_resolved_from_model_catalog = false;
|
||||
sc.original_config_do_not_use = Arc::new(config);
|
||||
sc.base_instructions = "catalog instructions".to_string();
|
||||
sc.developer_instructions = Some("catalog developer instructions".to_string());
|
||||
sc.compact_prompt = Some("catalog compact prompt".to_string());
|
||||
sc.service_tier = Some(codex_protocol::config_types::ServiceTier::Flex);
|
||||
|
||||
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
let lock = &lockfile.config;
|
||||
|
||||
assert_eq!(lock.model, None);
|
||||
assert_eq!(lock.model_reasoning_effort, None);
|
||||
assert_eq!(lock.model_reasoning_summary, None);
|
||||
assert_eq!(lock.service_tier, None);
|
||||
assert_eq!(lock.instructions, None);
|
||||
assert_eq!(lock.developer_instructions, None);
|
||||
assert_eq!(lock.compact_prompt, None);
|
||||
assert_eq!(lock.personality, None);
|
||||
assert_eq!(lock.approval_policy, None);
|
||||
assert_eq!(lock.approvals_reviewer, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_validation_reports_config_diff() {
|
||||
let sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let expected = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
let mut actual = expected.clone();
|
||||
actual.config.model = Some("different-model".to_string());
|
||||
|
||||
let error =
|
||||
validate_config_lock_replay(&expected, &actual, ConfigLockReplayOptions::default())
|
||||
.expect_err("config drift should fail");
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains("replayed effective config does not match config lock"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("model = "), "{message}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_validation_rejects_codex_version_mismatch_by_default() {
|
||||
let sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let mut expected = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
expected.codex_version = "older-version".to_string();
|
||||
let actual = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
|
||||
let error =
|
||||
validate_config_lock_replay(&expected, &actual, ConfigLockReplayOptions::default())
|
||||
.expect_err("version drift should fail");
|
||||
let message = error.to_string();
|
||||
assert!(
|
||||
message.contains("config lock Codex version mismatch"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("debug.config_lockfile.allow_codex_version_mismatch=true"),
|
||||
"{message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lock_validation_can_ignore_codex_version_mismatch() {
|
||||
let sc = crate::session::tests::make_session_configuration_for_tests().await;
|
||||
let mut expected = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
expected.codex_version = "older-version".to_string();
|
||||
let actual = sc.to_config_lockfile_toml().expect("lock should serialize");
|
||||
|
||||
validate_config_lock_replay(
|
||||
&expected,
|
||||
&actual,
|
||||
ConfigLockReplayOptions {
|
||||
allow_codex_version_mismatch: true,
|
||||
},
|
||||
)
|
||||
.expect("version drift should be ignored");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,10 @@ 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)]
|
||||
@@ -45,4 +49,8 @@ impl FeatureConfig for AppsMcpPathOverrideConfigToml {
|
||||
fn enabled(&self) -> Option<bool> {
|
||||
self.enabled.or(self.path.as_ref().map(|_| true))
|
||||
}
|
||||
|
||||
fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = Some(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -620,12 +651,20 @@ 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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -215,6 +215,10 @@ fn new_config(model: Option<String>, 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user