diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index e7a93d3dea..54db075a5f 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -30,6 +30,7 @@ rand = "0.9" reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +tempfile = "3" thiserror = "2.0.12" time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } tokio = { version = "1", features = [ @@ -59,5 +60,4 @@ openssl-sys = { version = "*", features = ["vendored"] } assert_cmd = "2" predicates = "3" pretty_assertions = "1.4.1" -tempfile = "3" wiremock = "0.6" diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a98a272417..32dcdd9953 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -610,7 +610,7 @@ async fn submission_loop( // `instructions` value into the Session struct. let session_id = Uuid::new_v4(); let rollout_recorder = - match RolloutRecorder::new(session_id, instructions.clone()).await { + match RolloutRecorder::new(&config, session_id, instructions.clone()).await { Ok(r) => Some(r), Err(e) => { tracing::warn!("failed to initialise rollout recorder: {e}"); diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 42c1684ac0..b5f21540d8 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -11,6 +11,7 @@ use serde::Deserialize; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; +use tempfile::TempDir; /// 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 @@ -77,6 +78,10 @@ pub struct Config { /// Maximum number of bytes to include from an AGENTS.md project doc file. pub project_doc_max_bytes: usize, + + /// Directory containing all Codex state (defaults to `~/.codex` but can be + /// overridden by the `CODEX_HOME` environment variable). + pub codex_home: PathBuf, } /// Base config deserialized from ~/.codex/config.toml. @@ -196,16 +201,20 @@ impl Config { pub fn load_with_overrides(overrides: ConfigOverrides) -> std::io::Result { let cfg: ConfigToml = ConfigToml::load_from_toml()?; tracing::warn!("Config parsed from config.toml: {cfg:?}"); - let codex_dir = codex_dir().ok(); - Self::load_from_base_config_with_overrides(cfg, overrides, codex_dir.as_deref()) + + // Resolve the directory that stores Codex state (e.g. ~/.codex or the + // value of $CODEX_HOME) so we can embed it into the resulting + // `Config` instance. + let codex_home = codex_dir()?; + Self::load_from_base_config_with_overrides(cfg, overrides, codex_home) } fn load_from_base_config_with_overrides( cfg: ConfigToml, overrides: ConfigOverrides, - codex_dir: Option<&Path>, + codex_home: PathBuf, ) -> std::io::Result { - let instructions = Self::load_instructions(codex_dir); + let instructions = Self::load_instructions(Some(&codex_home)); // Destructure ConfigOverrides fully to ensure all overrides are applied. let ConfigOverrides { @@ -308,6 +317,7 @@ impl Config { mcp_servers: cfg.mcp_servers, model_providers, project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES), + codex_home, }; Ok(config) } @@ -330,13 +340,15 @@ impl Config { } /// Meant to be used exclusively for tests: `load_with_overrides()` should - /// be used in all other cases. - pub fn load_default_config_for_test() -> Self { + /// be used in all other cases. Note TempDir is required to ensure tests + /// create a unique config directory for each test run so they do not + /// interfere with each other. + pub fn load_default_config_for_test(codex_home: &TempDir) -> Self { #[expect(clippy::expect_used)] Self::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), - None, + codex_home.path().to_path_buf(), ) .expect("defaults for test should always succeed") } @@ -346,9 +358,18 @@ fn default_model() -> String { OPENAI_DEFAULT_MODEL.to_string() } -/// Returns the path to the Codex configuration directory, which is `~/.codex`. -/// Does not verify that the directory exists. -pub fn codex_dir() -> std::io::Result { +/// Returns the path to the Codex configuration directory, which can be +/// specified by the `CODEX_HOME` environment variable. If not set, defaults to +/// `~/.codex`. This function does not verify that the directory exists. +fn codex_dir() -> std::io::Result { + // Honor the `CODEX_HOME` environment variable when it is set to allow users + // (and tests) to override the default location. + if let Ok(val) = std::env::var("CODEX_HOME") { + if !val.is_empty() { + return PathBuf::from(val).canonicalize(); + } + } + let mut p = home_dir().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::NotFound, @@ -361,8 +382,8 @@ pub fn codex_dir() -> std::io::Result { /// Returns the path to the folder where Codex logs are stored. Does not verify /// that the directory exists. -pub fn log_dir() -> std::io::Result { - let mut p = codex_dir()?; +pub fn log_dir(cfg: &Config) -> std::io::Result { + let mut p = cfg.codex_home.clone(); p.push("log"); Ok(p) } @@ -416,7 +437,6 @@ mod tests { #![allow(clippy::expect_used, clippy::unwrap_used)] use super::*; use pretty_assertions::assert_eq; - use tempfile::TempDir; /// Verify that the `sandbox_permissions` field on `ConfigToml` correctly /// differentiates between a value that is completely absent in the @@ -470,20 +490,26 @@ mod tests { assert!(msg.contains("not-a-real-permission")); } - /// Users can specify config values at multiple levels that have the - /// following precedence: - /// - /// 1. custom command-line argument, e.g. `--model o3` - /// 2. as part of a profile, where the `--profile` is specified via a CLI - /// (or in the config file itelf) - /// 3. as an entry in `config.toml`, e.g. `model = "o3"` - /// 4. the default value for a required field defined in code, e.g., - /// `crate::flags::OPENAI_DEFAULT_MODEL` - /// - /// Note that profiles are the recommended way to specify a group of - /// configuration options together. - #[test] - fn test_precedence_overrides_then_profile_then_config_toml() -> std::io::Result<()> { + struct PrecedenceTestFixture { + cwd: TempDir, + codex_home: TempDir, + cfg: ConfigToml, + model_provider_map: HashMap, + openai_provider: ModelProviderInfo, + openai_chat_completions_provider: ModelProviderInfo, + } + + impl PrecedenceTestFixture { + fn cwd(&self) -> PathBuf { + self.cwd.path().to_path_buf() + } + + fn codex_home(&self) -> PathBuf { + self.codex_home.path().to_path_buf() + } + } + + fn create_test_fixture() -> std::io::Result { let toml = r#" model = "o3" approval_policy = "unless-allow-listed" @@ -526,6 +552,8 @@ disable_response_storage = true // a parent folder, either. std::fs::write(cwd.join(".git"), "gitdir: nowhere")?; + let codex_home_temp_dir = TempDir::new().unwrap(); + let openai_chat_completions_provider = ModelProviderInfo { name: "OpenAI using Chat Completions".to_string(), base_url: "https://api.openai.com/v1".to_string(), @@ -547,94 +575,145 @@ disable_response_storage = true .expect("openai provider should exist") .clone(); + Ok(PrecedenceTestFixture { + cwd: cwd_temp_dir, + codex_home: codex_home_temp_dir, + cfg, + model_provider_map, + openai_provider, + openai_chat_completions_provider, + }) + } + + /// Users can specify config values at multiple levels that have the + /// following precedence: + /// + /// 1. custom command-line argument, e.g. `--model o3` + /// 2. as part of a profile, where the `--profile` is specified via a CLI + /// (or in the config file itelf) + /// 3. as an entry in `config.toml`, e.g. `model = "o3"` + /// 4. the default value for a required field defined in code, e.g., + /// `crate::flags::OPENAI_DEFAULT_MODEL` + /// + /// Note that profiles are the recommended way to specify a group of + /// configuration options together. + #[test] + fn test_precedence_overrides_then_profile_then_config_toml_o3_fixture() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + let o3_profile_overrides = ConfigOverrides { config_profile: Some("o3".to_string()), - cwd: Some(cwd.clone()), + cwd: Some(fixture.cwd()), ..Default::default() }; - let o3_profile_config = - Config::load_from_base_config_with_overrides(cfg.clone(), o3_profile_overrides, None)?; + let o3_profile_config: Config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + o3_profile_overrides, + fixture.codex_home(), + )?; assert_eq!( Config { model: "o3".to_string(), model_provider_id: "openai".to_string(), - model_provider: openai_provider.clone(), + model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), disable_response_storage: false, instructions: None, notify: None, - cwd: cwd.clone(), + cwd: fixture.cwd(), mcp_servers: HashMap::new(), - model_providers: model_provider_map.clone(), + model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, + codex_home: fixture.codex_home(), }, o3_profile_config ); + Ok(()) + } + + #[test] + fn test_precedence_overrides_then_profile_then_config_toml_gpt3_fixture() -> std::io::Result<()> + { + let fixture = create_test_fixture()?; let gpt3_profile_overrides = ConfigOverrides { config_profile: Some("gpt3".to_string()), - cwd: Some(cwd.clone()), + cwd: Some(fixture.cwd()), ..Default::default() }; let gpt3_profile_config = Config::load_from_base_config_with_overrides( - cfg.clone(), + fixture.cfg.clone(), gpt3_profile_overrides, - None, + fixture.codex_home(), )?; let expected_gpt3_profile_config = Config { model: "gpt-3.5-turbo".to_string(), model_provider_id: "openai-chat-completions".to_string(), - model_provider: openai_chat_completions_provider, + model_provider: fixture.openai_chat_completions_provider.clone(), approval_policy: AskForApproval::UnlessAllowListed, sandbox_policy: SandboxPolicy::new_read_only_policy(), disable_response_storage: false, instructions: None, notify: None, - cwd: cwd.clone(), + cwd: fixture.cwd(), mcp_servers: HashMap::new(), - model_providers: model_provider_map.clone(), + model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, + codex_home: fixture.codex_home(), }; - assert_eq!(expected_gpt3_profile_config.clone(), gpt3_profile_config); + + assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); // Verify that loading without specifying a profile in ConfigOverrides - // uses the default profile from the config file. + // uses the default profile from the config file (which is "gpt3"). let default_profile_overrides = ConfigOverrides { - cwd: Some(cwd.clone()), + cwd: Some(fixture.cwd()), ..Default::default() }; + let default_profile_config = Config::load_from_base_config_with_overrides( - cfg.clone(), + fixture.cfg.clone(), default_profile_overrides, - None, + fixture.codex_home(), )?; + assert_eq!(expected_gpt3_profile_config, default_profile_config); + Ok(()) + } + + #[test] + fn test_precedence_overrides_then_profile_then_config_toml_zdr_fixture() -> std::io::Result<()> + { + let fixture = create_test_fixture()?; let zdr_profile_overrides = ConfigOverrides { config_profile: Some("zdr".to_string()), - cwd: Some(cwd.clone()), + cwd: Some(fixture.cwd()), ..Default::default() }; - let zdr_profile_config = - Config::load_from_base_config_with_overrides(cfg.clone(), zdr_profile_overrides, None)?; - assert_eq!( - Config { - model: "o3".to_string(), - model_provider_id: "openai".to_string(), - model_provider: openai_provider.clone(), - approval_policy: AskForApproval::OnFailure, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - disable_response_storage: true, - instructions: None, - notify: None, - cwd: cwd.clone(), - mcp_servers: HashMap::new(), - model_providers: model_provider_map.clone(), - project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, - }, - zdr_profile_config - ); + let zdr_profile_config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + zdr_profile_overrides, + fixture.codex_home(), + )?; + let expected_zdr_profile_config = Config { + model: "o3".to_string(), + model_provider_id: "openai".to_string(), + model_provider: fixture.openai_provider.clone(), + approval_policy: AskForApproval::OnFailure, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + disable_response_storage: true, + instructions: None, + notify: None, + cwd: fixture.cwd(), + mcp_servers: HashMap::new(), + model_providers: fixture.model_provider_map.clone(), + project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, + codex_home: fixture.codex_home(), + }; + + assert_eq!(expected_zdr_profile_config, zdr_profile_config); Ok(()) } diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 1ba0dd701e..050fadef41 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -147,7 +147,8 @@ mod tests { /// value is cleared to mimic a scenario where no system instructions have /// been configured. fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -> Config { - let mut cfg = Config::load_default_config_for_test(); + let codex_home = TempDir::new().unwrap(); + let mut cfg = Config::load_default_config_for_test(&codex_home); cfg.cwd = root.path().to_path_buf(); cfg.project_doc_max_bytes = limit; diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index 7a014f401c..80b1f0a3fa 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -17,7 +17,7 @@ use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::{self}; use uuid::Uuid; -use crate::config::codex_dir; +use crate::config::Config; use crate::models::ResponseItem; /// Folder inside `~/.codex` that holds saved rollouts. @@ -49,12 +49,16 @@ impl RolloutRecorder { /// Attempt to create a new [`RolloutRecorder`]. If the sessions directory /// cannot be created or the rollout file cannot be opened we return the /// error so the caller can decide whether to disable persistence. - pub async fn new(uuid: Uuid, instructions: Option) -> std::io::Result { + pub async fn new( + config: &Config, + uuid: Uuid, + instructions: Option, + ) -> std::io::Result { let LogFileInfo { file, session_id, timestamp, - } = create_log_file(uuid)?; + } = create_log_file(config, uuid)?; // Build the static session metadata JSON first. let timestamp_format: &[FormatItem] = format_description!( @@ -154,9 +158,9 @@ struct LogFileInfo { timestamp: OffsetDateTime, } -fn create_log_file(session_id: Uuid) -> std::io::Result { +fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result { // Resolve ~/.codex/sessions and create it if missing. - let mut dir = codex_dir()?; + let mut dir = config.codex_home.clone(); dir.push(SESSIONS_SUBDIR); fs::create_dir_all(&dir)?; diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs index 83880d3471..20b0949292 100644 --- a/codex-rs/core/tests/live_agent.rs +++ b/codex-rs/core/tests/live_agent.rs @@ -27,6 +27,7 @@ use codex_core::protocol::ErrorEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use tempfile::TempDir; use tokio::sync::Notify; use tokio::time::timeout; @@ -57,7 +58,8 @@ async fn spawn_codex() -> Result { std::env::set_var("OPENAI_STREAM_MAX_RETRIES", "2"); } - let config = Config::load_default_config_for_test(); + let codex_home = TempDir::new().unwrap(); + let config = Config::load_default_config_for_test(&codex_home); let (agent, _init_id) = Codex::spawn(config, std::sync::Arc::new(Notify::new())).await?; Ok(agent) diff --git a/codex-rs/core/tests/previous_response_id.rs b/codex-rs/core/tests/previous_response_id.rs index f0ee840545..0bd946fa90 100644 --- a/codex-rs/core/tests/previous_response_id.rs +++ b/codex-rs/core/tests/previous_response_id.rs @@ -9,6 +9,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use serde_json::Value; +use tempfile::TempDir; use tokio::time::timeout; use wiremock::Match; use wiremock::Mock; @@ -108,7 +109,8 @@ async fn keeps_previous_response_id_between_tasks() { }; // Init session - let mut config = Config::load_default_config_for_test(); + let codex_home = TempDir::new().unwrap(); + let mut config = Config::load_default_config_for_test(&codex_home); config.model_provider = model_provider; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); let (codex, _init_id) = Codex::spawn(config, ctrl_c.clone()).await.unwrap(); diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs index 5b50d7ac26..2a28099128 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -9,6 +9,7 @@ use codex_core::config::Config; use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use tempfile::TempDir; use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; @@ -96,7 +97,8 @@ async fn retries_on_early_close() { }; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); - let mut config = Config::load_default_config_for_test(); + let codex_home = TempDir::new().unwrap(); + let mut config = Config::load_default_config_for_test(&codex_home); config.model_provider = model_provider; let (codex, _init_id) = Codex::spawn(config, ctrl_c).await.unwrap(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 3d339d26a1..bee6e1b7c8 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -69,7 +69,7 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> { } }; - let log_dir = codex_core::config::log_dir()?; + let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. let mut log_file_opts = OpenOptions::new();