diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a98a2724..32dcdd99 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 42c1684a..84f44bde 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -77,6 +77,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. @@ -132,8 +136,8 @@ impl ConfigToml { /// Attempt to parse the file at `~/.codex/config.toml`. If it does not /// exist, return a default config. Though if it exists and cannot be /// parsed, report that to the user and force them to fix it. - fn load_from_toml() -> std::io::Result { - let config_toml_path = codex_dir()?.join("config.toml"); + fn load_from_toml(codex_home: &Path) -> std::io::Result { + let config_toml_path = codex_home.join("config.toml"); match std::fs::read_to_string(&config_toml_path) { Ok(contents) => toml::from_str::(&contents).map_err(|e| { tracing::error!("Failed to parse config.toml: {e}"); @@ -161,7 +165,7 @@ where match permissions { Some(raw_permissions) => { - let base_path = codex_dir().map_err(serde::de::Error::custom)?; + let base_path = find_codex_home().map_err(serde::de::Error::custom)?; let converted = raw_permissions .into_iter() @@ -194,18 +198,25 @@ impl Config { /// ~/.codex/config.toml, ~/.codex/instructions.md, embedded defaults, and /// any values provided in `overrides` (highest precedence). pub fn load_with_overrides(overrides: ConfigOverrides) -> std::io::Result { - let cfg: ConfigToml = ConfigToml::load_from_toml()?; + // 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 = find_codex_home()?; + + let cfg: ConfigToml = ConfigToml::load_from_toml(&codex_home)?; 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()) + + Self::load_from_base_config_with_overrides(cfg, overrides, codex_home) } - fn load_from_base_config_with_overrides( + /// Meant to be used exclusively for tests: `load_with_overrides()` should + /// be used in all other cases. + pub 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 +319,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) } @@ -328,27 +340,29 @@ 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 { - #[expect(clippy::expect_used)] - Self::load_from_base_config_with_overrides( - ConfigToml::default(), - ConfigOverrides::default(), - None, - ) - .expect("defaults for test should always succeed") - } } 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`. +/// +/// - If `CODEX_HOME` is set, the value will be canonicalized and this +/// function will Err if the path does not exist. +/// - If `CODEX_HOME` is not set, this function does not verify that the +/// directory exists. +fn find_codex_home() -> 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 +375,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) } @@ -470,20 +484,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 +546,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 +569,143 @@ 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_fixture_with_o3_profile() -> 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_fixture_with_gpt3_profile() -> 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_fixture_with_zdr_profile() -> 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/lib.rs b/codex-rs/core/src/lib.rs index c4f38026..b4bc76ba 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -1,12 +1,11 @@ //! Root of the `codex-core` library. // Prevent accidental direct writes to stdout/stderr in library code. All -// user‑visible output must go through the appropriate abstraction (e.g., +// user-visible output must go through the appropriate abstraction (e.g., // the TUI or the tracing stack). #![deny(clippy::print_stdout, clippy::print_stderr)] mod chat_completions; - mod client; mod client_common; pub mod codex; diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 1ba0dd70..1a4e90de 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -137,7 +137,8 @@ mod tests { #![allow(clippy::expect_used, clippy::unwrap_used)] use super::*; - use crate::config::Config; + use crate::config::ConfigOverrides; + use crate::config::ConfigToml; use std::fs; use tempfile::TempDir; @@ -147,12 +148,19 @@ 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(); - cfg.cwd = root.path().to_path_buf(); - cfg.project_doc_max_bytes = limit; + let codex_home = TempDir::new().unwrap(); + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("defaults for test should always succeed"); - cfg.instructions = instructions.map(ToOwned::to_owned); - cfg + config.cwd = root.path().to_path_buf(); + config.project_doc_max_bytes = limit; + + config.instructions = instructions.map(ToOwned::to_owned); + config } /// AGENTS.md missing – should yield `None`. diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index 7a014f40..80b1f0a3 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 83880d34..bc5a1105 100644 --- a/codex-rs/core/tests/live_agent.rs +++ b/codex-rs/core/tests/live_agent.rs @@ -20,13 +20,15 @@ use std::time::Duration; use codex_core::Codex; -use codex_core::config::Config; use codex_core::error::CodexErr; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::ErrorEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; +mod test_support; +use tempfile::TempDir; +use test_support::load_default_config_for_test; use tokio::sync::Notify; use tokio::time::timeout; @@ -57,7 +59,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 = 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 f0ee8405..c3697a0e 100644 --- a/codex-rs/core/tests/previous_response_id.rs +++ b/codex-rs/core/tests/previous_response_id.rs @@ -2,13 +2,15 @@ use std::time::Duration; use codex_core::Codex; use codex_core::ModelProviderInfo; -use codex_core::config::Config; use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_core::protocol::ErrorEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; +mod test_support; use serde_json::Value; +use tempfile::TempDir; +use test_support::load_default_config_for_test; use tokio::time::timeout; use wiremock::Match; use wiremock::Mock; @@ -108,7 +110,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 = 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 5b50d7ac..247464f7 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -5,10 +5,12 @@ use std::time::Duration; use codex_core::Codex; use codex_core::ModelProviderInfo; -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; +mod test_support; +use tempfile::TempDir; +use test_support::load_default_config_for_test; use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; @@ -96,7 +98,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 = 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/core/tests/test_support.rs b/codex-rs/core/tests/test_support.rs new file mode 100644 index 00000000..532e3986 --- /dev/null +++ b/codex-rs/core/tests/test_support.rs @@ -0,0 +1,23 @@ +#![allow(clippy::expect_used)] + +// Helpers shared by the integration tests. These are located inside the +// `tests/` tree on purpose so they never become part of the public API surface +// of the `codex-core` crate. + +use tempfile::TempDir; + +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_core::config::ConfigToml; + +/// Returns a default `Config` whose on-disk state is confined to the provided +/// temporary directory. Using a per-test directory keeps tests hermetic and +/// avoids clobbering a developer’s real `~/.codex`. +pub fn load_default_config_for_test(codex_home: &TempDir) -> Config { + Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ) + .expect("defaults for test should always succeed") +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 3d339d26..bee6e1b7 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();