From a5b7675e42d8626529d09da5cb17668bc1197129 Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Fri, 3 Oct 2025 13:02:26 -0700 Subject: [PATCH] add(core): managed config (#3868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Factor `load_config_as_toml` into `core::config_loader` so config loading is reusable across callers. - Layer `~/.codex/config.toml`, optional `~/.codex/managed_config.toml`, and macOS managed preferences (base64) with recursive table merging and scoped threads per source. ## Config Flow ``` Managed prefs (macOS profile: com.openai.codex/config_toml_base64) ▲ │ ~/.codex/managed_config.toml │ (optional file-based override) ▲ │ ~/.codex/config.toml (user-defined settings) ``` - The loader searches under the resolved `CODEX_HOME` directory (defaults to `~/.codex`). - Managed configs let administrators ship fleet-wide overrides via device profiles which is useful for enforcing certain settings like sandbox or approval defaults. - For nested hash tables: overlays merge recursively. Child tables are merged key-by-key, while scalar or array values replace the prior layer entirely. This lets admins add or tweak individual fields without clobbering unrelated user settings. --- codex-rs/Cargo.lock | 1 + .../app-server/src/codex_message_processor.rs | 33 +- codex-rs/app-server/src/lib.rs | 1 + codex-rs/chatgpt/src/apply_command.rs | 3 +- codex-rs/cli/src/debug_sandbox.rs | 3 +- codex-rs/cli/src/login.rs | 14 +- codex-rs/cli/src/mcp_cmd.rs | 26 +- codex-rs/cli/tests/mcp_add_remove.rs | 16 +- codex-rs/core/Cargo.toml | 9 +- codex-rs/core/src/config.rs | 164 +++++---- codex-rs/core/src/config_loader/macos.rs | 118 +++++++ codex-rs/core/src/config_loader/mod.rs | 311 ++++++++++++++++++ codex-rs/core/src/lib.rs | 1 + codex-rs/exec/src/lib.rs | 2 +- codex-rs/mcp-server/src/codex_tool_config.rs | 5 +- codex-rs/mcp-server/src/lib.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 5 +- codex-rs/tui/src/chatwidget/tests.rs | 6 +- codex-rs/tui/src/lib.rs | 4 +- codex-rs/tui/src/markdown_stream.rs | 132 ++++---- codex-rs/tui/src/streaming/controller.rs | 15 +- 21 files changed, 676 insertions(+), 194 deletions(-) create mode 100644 codex-rs/core/src/config_loader/macos.rs create mode 100644 codex-rs/core/src/config_loader/mod.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c9228a0c..2079f600 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1035,6 +1035,7 @@ dependencies = [ "codex-protocol", "codex-rmcp-client", "codex-utils-string", + "core-foundation 0.9.4", "core_test_support", "dirs", "dunce", diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c158621d..cb894a51 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -500,7 +500,7 @@ impl CodexMessageProcessor { } async fn get_user_saved_config(&self, request_id: RequestId) { - let toml_value = match load_config_as_toml(&self.config.codex_home) { + let toml_value = match load_config_as_toml(&self.config.codex_home).await { Ok(val) => val, Err(err) => { let error = JSONRPCErrorError { @@ -653,18 +653,19 @@ impl CodexMessageProcessor { } async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) { - let config = match derive_config_from_params(params, self.codex_linux_sandbox_exe.clone()) { - Ok(config) => config, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let config = + match derive_config_from_params(params, self.codex_linux_sandbox_exe.clone()).await { + Ok(config) => config, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("error deriving config: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; match self.conversation_manager.new_conversation(config).await { Ok(conversation_id) => { @@ -752,7 +753,7 @@ impl CodexMessageProcessor { // Derive a Config using the same logic as new conversation, honoring overrides if provided. let config = match params.overrides { Some(overrides) => { - derive_config_from_params(overrides, self.codex_linux_sandbox_exe.clone()) + derive_config_from_params(overrides, self.codex_linux_sandbox_exe.clone()).await } None => Ok(self.config.as_ref().clone()), }; @@ -1320,7 +1321,7 @@ async fn apply_bespoke_event_handling( } } -fn derive_config_from_params( +async fn derive_config_from_params( params: NewConversationParams, codex_linux_sandbox_exe: Option, ) -> std::io::Result { @@ -1358,7 +1359,7 @@ fn derive_config_from_params( .map(|(k, v)| (k, json_to_toml(v))) .collect(); - Config::load_with_cli_overrides(cli_overrides, overrides) + Config::load_with_cli_overrides(cli_overrides, overrides).await } async fn on_patch_approval_response( diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 3e888c69..75a8be01 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -81,6 +81,7 @@ pub async fn run_main( ) })?; let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default()) + .await .map_err(|e| { std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) })?; diff --git a/codex-rs/chatgpt/src/apply_command.rs b/codex-rs/chatgpt/src/apply_command.rs index 656197f7..ea7c69d5 100644 --- a/codex-rs/chatgpt/src/apply_command.rs +++ b/codex-rs/chatgpt/src/apply_command.rs @@ -29,7 +29,8 @@ pub async fn run_apply_command( .parse_overrides() .map_err(anyhow::Error::msg)?, ConfigOverrides::default(), - )?; + ) + .await?; init_chatgpt_token_from_auth(&config.codex_home).await?; diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index a7d7103c..cde1f708 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -73,7 +73,8 @@ async fn run_command_under_sandbox( codex_linux_sandbox_exe, ..Default::default() }, - )?; + ) + .await?; // In practice, this should be `std::env::current_dir()` because this CLI // does not support `--cwd`, but let's use the config value for consistency. diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index cd20e2ba..5e69ede6 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -26,7 +26,7 @@ pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> { } pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { - let config = load_config_or_exit(cli_config_overrides); + let config = load_config_or_exit(cli_config_overrides).await; match login_with_chatgpt(config.codex_home).await { Ok(_) => { @@ -44,7 +44,7 @@ pub async fn run_login_with_api_key( cli_config_overrides: CliConfigOverrides, api_key: String, ) -> ! { - let config = load_config_or_exit(cli_config_overrides); + let config = load_config_or_exit(cli_config_overrides).await; match login_with_api_key(&config.codex_home, &api_key) { Ok(_) => { @@ -91,7 +91,7 @@ pub async fn run_login_with_device_code( issuer_base_url: Option, client_id: Option, ) -> ! { - let config = load_config_or_exit(cli_config_overrides); + let config = load_config_or_exit(cli_config_overrides).await; let mut opts = ServerOptions::new( config.codex_home, client_id.unwrap_or(CLIENT_ID.to_string()), @@ -112,7 +112,7 @@ pub async fn run_login_with_device_code( } pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { - let config = load_config_or_exit(cli_config_overrides); + let config = load_config_or_exit(cli_config_overrides).await; match CodexAuth::from_codex_home(&config.codex_home) { Ok(Some(auth)) => match auth.mode { @@ -143,7 +143,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { } pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { - let config = load_config_or_exit(cli_config_overrides); + let config = load_config_or_exit(cli_config_overrides).await; match logout(&config.codex_home) { Ok(true) => { @@ -161,7 +161,7 @@ pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { } } -fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { +async fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { let cli_overrides = match cli_config_overrides.parse_overrides() { Ok(v) => v, Err(e) => { @@ -171,7 +171,7 @@ fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { }; let config_overrides = ConfigOverrides::default(); - match Config::load_with_cli_overrides(cli_overrides, config_overrides) { + match Config::load_with_cli_overrides(cli_overrides, config_overrides).await { Ok(config) => config, Err(e) => { eprintln!("Error loading configuration: {e}"); diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index b0c601b4..a105dbba 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -113,22 +113,22 @@ impl McpCli { match subcommand { McpSubcommand::List(args) => { - run_list(&config_overrides, args)?; + run_list(&config_overrides, args).await?; } McpSubcommand::Get(args) => { - run_get(&config_overrides, args)?; + run_get(&config_overrides, args).await?; } McpSubcommand::Add(args) => { - run_add(&config_overrides, args)?; + run_add(&config_overrides, args).await?; } McpSubcommand::Remove(args) => { - run_remove(&config_overrides, args)?; + run_remove(&config_overrides, args).await?; } McpSubcommand::Login(args) => { run_login(&config_overrides, args).await?; } McpSubcommand::Logout(args) => { - run_logout(&config_overrides, args)?; + run_logout(&config_overrides, args).await?; } } @@ -136,7 +136,7 @@ impl McpCli { } } -fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> { +async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> { // Validate any provided overrides even though they are not currently applied. config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; @@ -162,6 +162,7 @@ fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<( let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; let mut servers = load_global_mcp_servers(&codex_home) + .await .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?; let new_entry = McpServerConfig { @@ -184,7 +185,7 @@ fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<( Ok(()) } -fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> { +async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> { config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; let RemoveArgs { name } = remove_args; @@ -193,6 +194,7 @@ fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; let mut servers = load_global_mcp_servers(&codex_home) + .await .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?; let removed = servers.remove(&name).is_some(); @@ -214,6 +216,7 @@ fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) -> Result<()> { let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) + .await .context("failed to load configuration")?; if !config.use_experimental_use_rmcp_client { @@ -238,9 +241,10 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) Ok(()) } -fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) -> Result<()> { +async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) -> Result<()> { let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) + .await .context("failed to load configuration")?; let LogoutArgs { name } = logout_args; @@ -264,9 +268,10 @@ fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) -> Ok(()) } -fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Result<()> { +async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Result<()> { let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) + .await .context("failed to load configuration")?; let mut entries: Vec<_> = config.mcp_servers.iter().collect(); @@ -424,9 +429,10 @@ fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Resul Ok(()) } -fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<()> { +async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<()> { let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) + .await .context("failed to load configuration")?; let Some(server) = config.mcp_servers.get(&get_args.name) else { diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index cf3ea9f7..6530760e 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -13,8 +13,8 @@ fn codex_command(codex_home: &Path) -> Result { Ok(cmd) } -#[test] -fn add_and_remove_server_updates_global_config() -> Result<()> { +#[tokio::test] +async fn add_and_remove_server_updates_global_config() -> Result<()> { let codex_home = TempDir::new()?; let mut add_cmd = codex_command(codex_home.path())?; @@ -24,7 +24,7 @@ fn add_and_remove_server_updates_global_config() -> Result<()> { .success() .stdout(contains("Added global MCP server 'docs'.")); - let servers = load_global_mcp_servers(codex_home.path())?; + let servers = load_global_mcp_servers(codex_home.path()).await?; assert_eq!(servers.len(), 1); let docs = servers.get("docs").expect("server should exist"); match &docs.transport { @@ -43,7 +43,7 @@ fn add_and_remove_server_updates_global_config() -> Result<()> { .success() .stdout(contains("Removed global MCP server 'docs'.")); - let servers = load_global_mcp_servers(codex_home.path())?; + let servers = load_global_mcp_servers(codex_home.path()).await?; assert!(servers.is_empty()); let mut remove_again_cmd = codex_command(codex_home.path())?; @@ -53,14 +53,14 @@ fn add_and_remove_server_updates_global_config() -> Result<()> { .success() .stdout(contains("No MCP server named 'docs' found.")); - let servers = load_global_mcp_servers(codex_home.path())?; + let servers = load_global_mcp_servers(codex_home.path()).await?; assert!(servers.is_empty()); Ok(()) } -#[test] -fn add_with_env_preserves_key_order_and_values() -> Result<()> { +#[tokio::test] +async fn add_with_env_preserves_key_order_and_values() -> Result<()> { let codex_home = TempDir::new()?; let mut add_cmd = codex_command(codex_home.path())?; @@ -80,7 +80,7 @@ fn add_with_env_preserves_key_order_and_values() -> Result<()> { .assert() .success(); - let servers = load_global_mcp_servers(codex_home.path())?; + let servers = load_global_mcp_servers(codex_home.path()).await?; let envy = servers.get("envy").expect("server should exist"); let env = match &envy.transport { McpServerTransportConfig::Stdio { env: Some(env), .. } => env, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 9c55ef9a..3d499f90 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -19,13 +19,13 @@ async-trait = { workspace = true } base64 = { workspace = true } bytes = { workspace = true } chrono = { workspace = true, features = ["serde"] } +codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } codex-file-search = { workspace = true } codex-mcp-client = { workspace = true } -codex-rmcp-client = { workspace = true } -codex-protocol = { workspace = true } -codex-app-server-protocol = { workspace = true } codex-otel = { workspace = true, features = ["otel"] } +codex-protocol = { workspace = true } +codex-rmcp-client = { workspace = true } codex-utils-string = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } @@ -76,6 +76,9 @@ wildmatch = { workspace = true } landlock = { workspace = true } seccompiler = { workspace = true } +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation = "0.9" + # Build OpenSSL from source for musl builds. [target.x86_64-unknown-linux-musl.dependencies] openssl-sys = { workspace = true, features = ["vendored"] } diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index afc9ff0f..2fe52bf4 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1,3 +1,7 @@ +use crate::config_loader::LoadedConfigLayers; +pub use crate::config_loader::load_config_as_toml; +use crate::config_loader::load_config_layers_with_overrides; +use crate::config_loader::merge_toml_values; use crate::config_profile::ConfigProfile; use crate::config_types::DEFAULT_OTEL_ENVIRONMENT; use crate::config_types::History; @@ -212,50 +216,38 @@ pub struct Config { } impl Config { - /// Load configuration with *generic* CLI overrides (`-c key=value`) applied - /// **in between** the values parsed from `config.toml` and the - /// strongly-typed overrides specified via [`ConfigOverrides`]. - /// - /// The precedence order is therefore: `config.toml` < `-c` overrides < - /// `ConfigOverrides`. - pub fn load_with_cli_overrides( + pub async fn load_with_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, overrides: ConfigOverrides, ) -> std::io::Result { - // 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()?; - // Step 1: parse `config.toml` into a generic JSON value. - let mut root_value = load_config_as_toml(&codex_home)?; + let root_value = load_resolved_config( + &codex_home, + cli_overrides, + crate::config_loader::LoaderOverrides::default(), + ) + .await?; - // Step 2: apply the `-c` overrides. - for (path, value) in cli_overrides.into_iter() { - apply_toml_override(&mut root_value, &path, value); - } - - // Step 3: deserialize into `ConfigToml` so that Serde can enforce the - // correct types. let cfg: ConfigToml = root_value.try_into().map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); std::io::Error::new(std::io::ErrorKind::InvalidData, e) })?; - // Step 4: merge with the strongly-typed overrides. Self::load_from_base_config_with_overrides(cfg, overrides, codex_home) } } -pub fn load_config_as_toml_with_cli_overrides( +pub async fn load_config_as_toml_with_cli_overrides( codex_home: &Path, cli_overrides: Vec<(String, TomlValue)>, ) -> std::io::Result { - let mut root_value = load_config_as_toml(codex_home)?; - - for (path, value) in cli_overrides.into_iter() { - apply_toml_override(&mut root_value, &path, value); - } + let root_value = load_resolved_config( + codex_home, + cli_overrides, + crate::config_loader::LoaderOverrides::default(), + ) + .await?; let cfg: ConfigToml = root_value.try_into().map_err(|e| { tracing::error!("Failed to deserialize overridden config: {e}"); @@ -265,33 +257,40 @@ pub fn load_config_as_toml_with_cli_overrides( Ok(cfg) } -/// Read `CODEX_HOME/config.toml` and return it as a generic TOML value. Returns -/// an empty TOML table when the file does not exist. -pub fn load_config_as_toml(codex_home: &Path) -> std::io::Result { - let config_path = codex_home.join(CONFIG_TOML_FILE); - match std::fs::read_to_string(&config_path) { - Ok(contents) => match toml::from_str::(&contents) { - Ok(val) => Ok(val), - Err(e) => { - tracing::error!("Failed to parse config.toml: {e}"); - Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)) - } - }, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - tracing::info!("config.toml not found, using defaults"); - Ok(TomlValue::Table(Default::default())) - } - Err(e) => { - tracing::error!("Failed to read config.toml: {e}"); - Err(e) - } - } +async fn load_resolved_config( + codex_home: &Path, + cli_overrides: Vec<(String, TomlValue)>, + overrides: crate::config_loader::LoaderOverrides, +) -> std::io::Result { + let layers = load_config_layers_with_overrides(codex_home, overrides).await?; + Ok(apply_overlays(layers, cli_overrides)) } -pub fn load_global_mcp_servers( +fn apply_overlays( + layers: LoadedConfigLayers, + cli_overrides: Vec<(String, TomlValue)>, +) -> TomlValue { + let LoadedConfigLayers { + mut base, + managed_config, + managed_preferences, + } = layers; + + for (path, value) in cli_overrides.into_iter() { + apply_toml_override(&mut base, &path, value); + } + + for overlay in [managed_config, managed_preferences].into_iter().flatten() { + merge_toml_values(&mut base, &overlay); + } + + base +} + +pub async fn load_global_mcp_servers( codex_home: &Path, ) -> std::io::Result> { - let root_value = load_config_as_toml(codex_home)?; + let root_value = load_config_as_toml(codex_home).await?; let Some(servers_value) = root_value.get("mcp_servers") else { return Ok(BTreeMap::new()); }; @@ -1329,18 +1328,18 @@ exclude_slash_tmp = true ); } - #[test] - fn load_global_mcp_servers_returns_empty_if_missing() -> anyhow::Result<()> { + #[tokio::test] + async fn load_global_mcp_servers_returns_empty_if_missing() -> anyhow::Result<()> { let codex_home = TempDir::new()?; - let servers = load_global_mcp_servers(codex_home.path())?; + let servers = load_global_mcp_servers(codex_home.path()).await?; assert!(servers.is_empty()); Ok(()) } - #[test] - fn write_global_mcp_servers_round_trips_entries() -> anyhow::Result<()> { + #[tokio::test] + async fn write_global_mcp_servers_round_trips_entries() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let mut servers = BTreeMap::new(); @@ -1359,7 +1358,7 @@ exclude_slash_tmp = true write_global_mcp_servers(codex_home.path(), &servers)?; - let loaded = load_global_mcp_servers(codex_home.path())?; + let loaded = load_global_mcp_servers(codex_home.path()).await?; assert_eq!(loaded.len(), 1); let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { @@ -1375,14 +1374,47 @@ exclude_slash_tmp = true let empty = BTreeMap::new(); write_global_mcp_servers(codex_home.path(), &empty)?; - let loaded = load_global_mcp_servers(codex_home.path())?; + let loaded = load_global_mcp_servers(codex_home.path()).await?; assert!(loaded.is_empty()); Ok(()) } - #[test] - fn load_global_mcp_servers_accepts_legacy_ms_field() -> anyhow::Result<()> { + #[tokio::test] + async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let managed_path = codex_home.path().join("managed_config.toml"); + + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + "model = \"base\"\n", + )?; + std::fs::write(&managed_path, "model = \"managed_config\"\n")?; + + let overrides = crate::config_loader::LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }; + + let root_value = load_resolved_config( + codex_home.path(), + vec![("model".to_string(), TomlValue::String("cli".to_string()))], + overrides, + ) + .await?; + + let cfg: ConfigToml = root_value.try_into().map_err(|e| { + tracing::error!("Failed to deserialize overridden config: {e}"); + std::io::Error::new(std::io::ErrorKind::InvalidData, e) + })?; + + assert_eq!(cfg.model.as_deref(), Some("managed_config")); + Ok(()) + } + + #[tokio::test] + async fn load_global_mcp_servers_accepts_legacy_ms_field() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let config_path = codex_home.path().join(CONFIG_TOML_FILE); @@ -1396,15 +1428,15 @@ startup_timeout_ms = 2500 "#, )?; - let servers = load_global_mcp_servers(codex_home.path())?; + let servers = load_global_mcp_servers(codex_home.path()).await?; let docs = servers.get("docs").expect("docs entry"); assert_eq!(docs.startup_timeout_sec, Some(Duration::from_millis(2500))); Ok(()) } - #[test] - fn write_global_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { + #[tokio::test] + async fn write_global_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let servers = BTreeMap::from([( @@ -1439,7 +1471,7 @@ ZIG_VAR = "3" "# ); - let loaded = load_global_mcp_servers(codex_home.path())?; + let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::Stdio { command, args, env } => { @@ -1457,8 +1489,8 @@ ZIG_VAR = "3" Ok(()) } - #[test] - fn write_global_mcp_servers_serializes_streamable_http() -> anyhow::Result<()> { + #[tokio::test] + async fn write_global_mcp_servers_serializes_streamable_http() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let mut servers = BTreeMap::from([( @@ -1486,7 +1518,7 @@ startup_timeout_sec = 2.0 "# ); - let loaded = load_global_mcp_servers(codex_home.path())?; + let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::StreamableHttp { url, bearer_token } => { @@ -1518,7 +1550,7 @@ url = "https://example.com/mcp" "# ); - let loaded = load_global_mcp_servers(codex_home.path())?; + let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::StreamableHttp { url, bearer_token } => { diff --git a/codex-rs/core/src/config_loader/macos.rs b/codex-rs/core/src/config_loader/macos.rs new file mode 100644 index 00000000..036619e0 --- /dev/null +++ b/codex-rs/core/src/config_loader/macos.rs @@ -0,0 +1,118 @@ +use std::io; +use toml::Value as TomlValue; + +#[cfg(target_os = "macos")] +mod native { + use super::*; + use base64::Engine; + use base64::prelude::BASE64_STANDARD; + use core_foundation::base::TCFType; + use core_foundation::string::CFString; + use core_foundation::string::CFStringRef; + use std::ffi::c_void; + use tokio::task; + + pub(crate) async fn load_managed_admin_config_layer( + override_base64: Option<&str>, + ) -> io::Result> { + if let Some(encoded) = override_base64 { + let trimmed = encoded.trim(); + return if trimmed.is_empty() { + Ok(None) + } else { + parse_managed_preferences_base64(trimmed).map(Some) + }; + } + + const LOAD_ERROR: &str = "Failed to load managed preferences configuration"; + + match task::spawn_blocking(load_managed_admin_config).await { + Ok(result) => result, + Err(join_err) => { + if join_err.is_cancelled() { + tracing::error!("Managed preferences load task was cancelled"); + } else { + tracing::error!("Managed preferences load task failed: {join_err}"); + } + Err(io::Error::other(LOAD_ERROR)) + } + } + } + + pub(super) fn load_managed_admin_config() -> io::Result> { + #[link(name = "CoreFoundation", kind = "framework")] + unsafe extern "C" { + fn CFPreferencesCopyAppValue( + key: CFStringRef, + application_id: CFStringRef, + ) -> *mut c_void; + } + + const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex"; + const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64"; + + let application_id = CFString::new(MANAGED_PREFERENCES_APPLICATION_ID); + let key = CFString::new(MANAGED_PREFERENCES_CONFIG_KEY); + + let value_ref = unsafe { + CFPreferencesCopyAppValue( + key.as_concrete_TypeRef(), + application_id.as_concrete_TypeRef(), + ) + }; + + if value_ref.is_null() { + tracing::debug!( + "Managed preferences for {} key {} not found", + MANAGED_PREFERENCES_APPLICATION_ID, + MANAGED_PREFERENCES_CONFIG_KEY + ); + return Ok(None); + } + + let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) }; + let contents = value.to_string(); + let trimmed = contents.trim(); + + parse_managed_preferences_base64(trimmed).map(Some) + } + + pub(super) fn parse_managed_preferences_base64(encoded: &str) -> io::Result { + let decoded = BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| { + tracing::error!("Failed to decode managed preferences as base64: {err}"); + io::Error::new(io::ErrorKind::InvalidData, err) + })?; + + let decoded_str = String::from_utf8(decoded).map_err(|err| { + tracing::error!("Managed preferences base64 contents were not valid UTF-8: {err}"); + io::Error::new(io::ErrorKind::InvalidData, err) + })?; + + match toml::from_str::(&decoded_str) { + Ok(TomlValue::Table(parsed)) => Ok(TomlValue::Table(parsed)), + Ok(other) => { + tracing::error!( + "Managed preferences TOML must have a table at the root, found {other:?}", + ); + Err(io::Error::new( + io::ErrorKind::InvalidData, + "managed preferences root must be a table", + )) + } + Err(err) => { + tracing::error!("Failed to parse managed preferences TOML: {err}"); + Err(io::Error::new(io::ErrorKind::InvalidData, err)) + } + } + } +} + +#[cfg(target_os = "macos")] +pub(crate) use native::load_managed_admin_config_layer; + +#[cfg(not(target_os = "macos"))] +pub(crate) async fn load_managed_admin_config_layer( + _override_base64: Option<&str>, +) -> io::Result> { + Ok(None) +} diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs new file mode 100644 index 00000000..6b55b015 --- /dev/null +++ b/codex-rs/core/src/config_loader/mod.rs @@ -0,0 +1,311 @@ +mod macos; + +use crate::config::CONFIG_TOML_FILE; +use macos::load_managed_admin_config_layer; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use tokio::fs; +use toml::Value as TomlValue; + +#[cfg(unix)] +const CODEX_MANAGED_CONFIG_SYSTEM_PATH: &str = "/etc/codex/managed_config.toml"; + +#[derive(Debug)] +pub(crate) struct LoadedConfigLayers { + pub base: TomlValue, + pub managed_config: Option, + pub managed_preferences: Option, +} + +#[derive(Debug, Default)] +pub(crate) struct LoaderOverrides { + pub managed_config_path: Option, + #[cfg(target_os = "macos")] + pub managed_preferences_base64: Option, +} + +// Configuration layering pipeline (top overrides bottom): +// +// +-------------------------+ +// | Managed preferences (*) | +// +-------------------------+ +// ^ +// | +// +-------------------------+ +// | managed_config.toml | +// +-------------------------+ +// ^ +// | +// +-------------------------+ +// | config.toml (base) | +// +-------------------------+ +// +// (*) Only available on macOS via managed device profiles. + +pub async fn load_config_as_toml(codex_home: &Path) -> io::Result { + load_config_as_toml_with_overrides(codex_home, LoaderOverrides::default()).await +} + +fn default_empty_table() -> TomlValue { + TomlValue::Table(Default::default()) +} + +pub(crate) async fn load_config_layers_with_overrides( + codex_home: &Path, + overrides: LoaderOverrides, +) -> io::Result { + load_config_layers_internal(codex_home, overrides).await +} + +async fn load_config_as_toml_with_overrides( + codex_home: &Path, + overrides: LoaderOverrides, +) -> io::Result { + let layers = load_config_layers_internal(codex_home, overrides).await?; + Ok(apply_managed_layers(layers)) +} + +async fn load_config_layers_internal( + codex_home: &Path, + overrides: LoaderOverrides, +) -> io::Result { + #[cfg(target_os = "macos")] + let LoaderOverrides { + managed_config_path, + managed_preferences_base64, + } = overrides; + + #[cfg(not(target_os = "macos"))] + let LoaderOverrides { + managed_config_path, + } = overrides; + + let managed_config_path = + managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home)); + + let user_config_path = codex_home.join(CONFIG_TOML_FILE); + let user_config = read_config_from_path(&user_config_path, true).await?; + let managed_config = read_config_from_path(&managed_config_path, false).await?; + + #[cfg(target_os = "macos")] + let managed_preferences = + load_managed_admin_config_layer(managed_preferences_base64.as_deref()).await?; + + #[cfg(not(target_os = "macos"))] + let managed_preferences = load_managed_admin_config_layer(None).await?; + + Ok(LoadedConfigLayers { + base: user_config.unwrap_or_else(default_empty_table), + managed_config, + managed_preferences, + }) +} + +async fn read_config_from_path( + path: &Path, + log_missing_as_info: bool, +) -> io::Result> { + match fs::read_to_string(path).await { + Ok(contents) => match toml::from_str::(&contents) { + Ok(value) => Ok(Some(value)), + Err(err) => { + tracing::error!("Failed to parse {}: {err}", path.display()); + Err(io::Error::new(io::ErrorKind::InvalidData, err)) + } + }, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + if log_missing_as_info { + tracing::info!("{} not found, using defaults", path.display()); + } else { + tracing::debug!("{} not found", path.display()); + } + Ok(None) + } + Err(err) => { + tracing::error!("Failed to read {}: {err}", path.display()); + Err(err) + } + } +} + +/// Merge config `overlay` into `base`, giving `overlay` precedence. +pub(crate) fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) { + if let TomlValue::Table(overlay_table) = overlay + && let TomlValue::Table(base_table) = base + { + for (key, value) in overlay_table { + if let Some(existing) = base_table.get_mut(key) { + merge_toml_values(existing, value); + } else { + base_table.insert(key.clone(), value.clone()); + } + } + } else { + *base = overlay.clone(); + } +} + +fn managed_config_default_path(codex_home: &Path) -> PathBuf { + #[cfg(unix)] + { + let _ = codex_home; + PathBuf::from(CODEX_MANAGED_CONFIG_SYSTEM_PATH) + } + + #[cfg(not(unix))] + { + codex_home.join("managed_config.toml") + } +} + +fn apply_managed_layers(layers: LoadedConfigLayers) -> TomlValue { + let LoadedConfigLayers { + mut base, + managed_config, + managed_preferences, + } = layers; + + for overlay in [managed_config, managed_preferences].into_iter().flatten() { + merge_toml_values(&mut base, &overlay); + } + + base +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn merges_managed_config_layer_on_top() { + let tmp = tempdir().expect("tempdir"); + let managed_path = tmp.path().join("managed_config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#"foo = 1 + +[nested] +value = "base" +"#, + ) + .expect("write base"); + std::fs::write( + &managed_path, + r#"foo = 2 + +[nested] +value = "managed_config" +extra = true +"#, + ) + .expect("write managed config"); + + let overrides = LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }; + + let loaded = load_config_as_toml_with_overrides(tmp.path(), overrides) + .await + .expect("load config"); + let table = loaded.as_table().expect("top-level table expected"); + + assert_eq!(table.get("foo"), Some(&TomlValue::Integer(2))); + let nested = table + .get("nested") + .and_then(|v| v.as_table()) + .expect("nested"); + assert_eq!( + nested.get("value"), + Some(&TomlValue::String("managed_config".to_string())) + ); + assert_eq!(nested.get("extra"), Some(&TomlValue::Boolean(true))); + } + + #[tokio::test] + async fn returns_empty_when_all_layers_missing() { + let tmp = tempdir().expect("tempdir"); + let managed_path = tmp.path().join("managed_config.toml"); + let overrides = LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }; + + let layers = load_config_layers_with_overrides(tmp.path(), overrides) + .await + .expect("load layers"); + let base_table = layers.base.as_table().expect("base table expected"); + assert!( + base_table.is_empty(), + "expected empty base layer when configs missing" + ); + assert!( + layers.managed_config.is_none(), + "managed config layer should be absent when file missing" + ); + + #[cfg(not(target_os = "macos"))] + { + let loaded = load_config_as_toml(tmp.path()).await.expect("load config"); + let table = loaded.as_table().expect("top-level table expected"); + assert!( + table.is_empty(), + "expected empty table when configs missing" + ); + } + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn managed_preferences_take_highest_precedence() { + use base64::Engine; + + let managed_payload = r#" +[nested] +value = "managed" +flag = false +"#; + let encoded = base64::prelude::BASE64_STANDARD.encode(managed_payload.as_bytes()); + let tmp = tempdir().expect("tempdir"); + let managed_path = tmp.path().join("managed_config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#"[nested] +value = "base" +"#, + ) + .expect("write base"); + std::fs::write( + &managed_path, + r#"[nested] +value = "managed_config" +flag = true +"#, + ) + .expect("write managed config"); + + let overrides = LoaderOverrides { + managed_config_path: Some(managed_path), + managed_preferences_base64: Some(encoded), + }; + + let loaded = load_config_as_toml_with_overrides(tmp.path(), overrides) + .await + .expect("load config"); + let nested = loaded + .get("nested") + .and_then(|v| v.as_table()) + .expect("nested table"); + assert_eq!( + nested.get("value"), + Some(&TomlValue::String("managed".to_string())) + ); + assert_eq!(nested.get("flag"), Some(&TomlValue::Boolean(false))); + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 0d42f6f1..08baa2bd 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -18,6 +18,7 @@ pub use codex_conversation::CodexConversation; mod command_safety; pub mod config; pub mod config_edit; +pub mod config_loader; pub mod config_profile; pub mod config_types; mod conversation_history; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d2e06aa6..e3bf6e52 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -191,7 +191,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any } }; - let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?; + let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?; let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION")); diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 3ee16690..3cd2cccd 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -132,7 +132,7 @@ pub(crate) fn create_tool_for_codex_tool_call_param() -> Tool { impl CodexToolCallParam { /// Returns the initial user prompt to start the Codex conversation and the /// effective Config object generated from the supplied parameters. - pub fn into_config( + pub async fn into_config( self, codex_linux_sandbox_exe: Option, ) -> std::io::Result<(String, codex_core::config::Config)> { @@ -172,7 +172,8 @@ impl CodexToolCallParam { .map(|(k, v)| (k, json_to_toml(v))) .collect(); - let cfg = codex_core::config::Config::load_with_cli_overrides(cli_overrides, overrides)?; + let cfg = + codex_core::config::Config::load_with_cli_overrides(cli_overrides, overrides).await?; Ok((prompt, cfg)) } diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index ffc4b3e3..8da5b405 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -91,6 +91,7 @@ pub async fn run_main( ) })?; let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default()) + .await .map_err(|e| { std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) })?; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index bb24f5f0..b21d5a65 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -342,7 +342,10 @@ impl MessageProcessor { async fn handle_tool_call_codex(&self, id: RequestId, arguments: Option) { let (initial_prompt, config): (String, Config) = match arguments { Some(json_val) => match serde_json::from_value::(json_val) { - Ok(tool_cfg) => match tool_cfg.into_config(self.codex_linux_sandbox_exe.clone()) { + Ok(tool_cfg) => match tool_cfg + .into_config(self.codex_linux_sandbox_exe.clone()) + .await + { Ok(cfg) => cfg, Err(e) => { let result = CallToolResult { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 7a8015b1..2071832f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -214,7 +214,7 @@ fn exited_review_mode_emits_results_and_finishes() { target_os = "macos", ignore = "system configuration APIs are blocked under macOS seatbelt" )] -#[tokio::test(flavor = "current_thread")] +#[tokio::test] async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); @@ -911,7 +911,7 @@ fn review_custom_prompt_escape_navigates_back_then_dismisses() { /// Opening base-branch picker from the review popup, pressing Esc returns to the /// parent popup, pressing Esc again dismisses all panels (back to normal mode). -#[tokio::test(flavor = "current_thread")] +#[tokio::test] async fn review_branch_picker_escape_navigates_back_then_dismisses() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); @@ -1099,7 +1099,7 @@ fn disabled_slash_command_while_task_running_snapshot() { assert_snapshot!(blob); } -#[tokio::test(flavor = "current_thread")] +#[tokio::test] async fn binary_size_transcript_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7807bf0a..28d2a3f0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -162,7 +162,7 @@ pub async fn run_main( // Load configuration and support CLI overrides. #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides) { + match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides).await { Ok(config) => config, Err(err) => { eprintln!("Error loading configuration: {err}"); @@ -182,7 +182,7 @@ pub async fn run_main( } }; - match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides) { + match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides).await { Ok(config_toml) => config_toml, Err(err) => { eprintln!("Error loading config.toml: {err}"); diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index efe58118..21e098c2 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -124,20 +124,19 @@ mod tests { use codex_core::config::ConfigOverrides; use ratatui::style::Color; - fn test_config() -> Config { + async fn test_config() -> Config { let overrides = ConfigOverrides { cwd: std::env::current_dir().ok(), ..Default::default() }; - match Config::load_with_cli_overrides(vec![], overrides) { - Ok(c) => c, - Err(e) => panic!("load test config: {e}"), - } + Config::load_with_cli_overrides(vec![], overrides) + .await + .expect("load test config") } - #[test] - fn no_commit_until_newline() { - let cfg = test_config(); + #[tokio::test] + async fn no_commit_until_newline() { + let cfg = test_config().await; let mut c = super::MarkdownStreamCollector::new(None); c.push_delta("Hello, world"); let out = c.commit_complete_lines(&cfg); @@ -147,18 +146,18 @@ mod tests { assert_eq!(out2.len(), 1, "one completed line after newline"); } - #[test] - fn finalize_commits_partial_line() { - let cfg = test_config(); + #[tokio::test] + async fn finalize_commits_partial_line() { + let cfg = test_config().await; let mut c = super::MarkdownStreamCollector::new(None); c.push_delta("Line without newline"); let out = c.finalize_and_drain(&cfg); assert_eq!(out.len(), 1); } - #[test] - fn e2e_stream_blockquote_simple_is_green() { - let cfg = test_config(); + #[tokio::test] + async fn e2e_stream_blockquote_simple_is_green() { + let cfg = test_config().await; let out = super::simulate_stream_markdown_for_tests(&["> Hello\n"], true, &cfg); assert_eq!(out.len(), 1); let l = &out[0]; @@ -170,9 +169,9 @@ mod tests { ); } - #[test] - fn e2e_stream_blockquote_nested_is_green() { - let cfg = test_config(); + #[tokio::test] + async fn e2e_stream_blockquote_nested_is_green() { + let cfg = test_config().await; let out = super::simulate_stream_markdown_for_tests(&["> Level 1\n>> Level 2\n"], true, &cfg); // Filter out any blank lines that may be inserted at paragraph starts. @@ -195,9 +194,9 @@ mod tests { assert_eq!(non_blank[1].style.fg, Some(Color::Green)); } - #[test] - fn e2e_stream_blockquote_with_list_items_is_green() { - let cfg = test_config(); + #[tokio::test] + async fn e2e_stream_blockquote_with_list_items_is_green() { + let cfg = test_config().await; let out = super::simulate_stream_markdown_for_tests(&["> - item 1\n> - item 2\n"], true, &cfg); assert_eq!(out.len(), 2); @@ -205,9 +204,9 @@ mod tests { assert_eq!(out[1].style.fg, Some(Color::Green)); } - #[test] - fn e2e_stream_nested_mixed_lists_ordered_marker_is_light_blue() { - let cfg = test_config(); + #[tokio::test] + async fn e2e_stream_nested_mixed_lists_ordered_marker_is_light_blue() { + let cfg = test_config().await; let md = [ "1. First\n", " - Second level\n", @@ -237,9 +236,9 @@ mod tests { ); } - #[test] - fn e2e_stream_blockquote_wrap_preserves_green_style() { - let cfg = test_config(); + #[tokio::test] + async fn e2e_stream_blockquote_wrap_preserves_green_style() { + let cfg = test_config().await; let long = "> This is a very long quoted line that should wrap across multiple columns to verify style preservation."; let out = super::simulate_stream_markdown_for_tests(&[long, "\n"], true, &cfg); // Wrap to a narrow width to force multiple output lines. @@ -273,9 +272,9 @@ mod tests { } } - #[test] - fn heading_starts_on_new_line_when_following_paragraph() { - let cfg = test_config(); + #[tokio::test] + async fn heading_starts_on_new_line_when_following_paragraph() { + let cfg = test_config().await; // Stream a paragraph line, then a heading on the next line. // Expect two distinct rendered lines: "Hello." and "Heading". @@ -330,9 +329,9 @@ mod tests { assert_eq!(line_to_string(&out2[1]), "## Heading"); } - #[test] - fn heading_not_inlined_when_split_across_chunks() { - let cfg = test_config(); + #[tokio::test] + async fn heading_not_inlined_when_split_across_chunks() { + let cfg = test_config().await; // Paragraph without trailing newline, then a chunk that starts with the newline // and the heading text, then a final newline. The collector should first commit @@ -413,18 +412,18 @@ mod tests { .collect() } - #[test] - fn lists_and_fences_commit_without_duplication() { + #[tokio::test] + async fn lists_and_fences_commit_without_duplication() { // List case - assert_streamed_equals_full(&["- a\n- ", "b\n- c\n"]); + assert_streamed_equals_full(&["- a\n- ", "b\n- c\n"]).await; // Fenced code case: stream in small chunks - assert_streamed_equals_full(&["```", "\nco", "de 1\ncode 2\n", "```\n"]); + assert_streamed_equals_full(&["```", "\nco", "de 1\ncode 2\n", "```\n"]).await; } - #[test] - fn utf8_boundary_safety_and_wide_chars() { - let cfg = test_config(); + #[tokio::test] + async fn utf8_boundary_safety_and_wide_chars() { + let cfg = test_config().await; // Emoji (wide), CJK, control char, digit + combining macron sequences let input = "🙂🙂🙂\n汉字漢字\nA\u{0003}0\u{0304}\n"; @@ -453,9 +452,9 @@ mod tests { ); } - #[test] - fn e2e_stream_deep_nested_third_level_marker_is_light_blue() { - let cfg = test_config(); + #[tokio::test] + async fn e2e_stream_deep_nested_third_level_marker_is_light_blue() { + let cfg = test_config().await; let md = "1. First\n - Second level\n 1. Third level (ordered)\n - Fourth level (bullet)\n - Fifth level to test indent consistency\n"; let streamed = super::simulate_stream_markdown_for_tests(&[md], true, &cfg); let streamed_strs = lines_to_plain_strings(&streamed); @@ -503,9 +502,9 @@ mod tests { ); } - #[test] - fn empty_fenced_block_is_dropped_and_separator_preserved_before_heading() { - let cfg = test_config(); + #[tokio::test] + async fn empty_fenced_block_is_dropped_and_separator_preserved_before_heading() { + let cfg = test_config().await; // An empty fenced code block followed by a heading should not render the fence, // but should preserve a blank separator line so the heading starts on a new line. let deltas = vec!["```bash\n```\n", "## Heading\n"]; // empty block and close in same commit @@ -522,9 +521,9 @@ mod tests { ); } - #[test] - fn paragraph_then_empty_fence_then_heading_keeps_heading_on_new_line() { - let cfg = test_config(); + #[tokio::test] + async fn paragraph_then_empty_fence_then_heading_keeps_heading_on_new_line() { + let cfg = test_config().await; let deltas = vec!["Para.\n", "```\n```\n", "## Title\n"]; // empty fence block in one commit let streamed = simulate_stream_markdown_for_tests(&deltas, true, &cfg); let texts = lines_to_plain_strings(&streamed); @@ -542,9 +541,9 @@ mod tests { ); } - #[test] - fn loose_list_with_split_dashes_matches_full_render() { - let cfg = test_config(); + #[tokio::test] + async fn loose_list_with_split_dashes_matches_full_render() { + let cfg = test_config().await; // Minimized failing sequence discovered by the helper: two chunks // that still reproduce the mismatch. let deltas = vec!["- item.\n\n", "-"]; @@ -563,9 +562,9 @@ mod tests { ); } - #[test] - fn loose_vs_tight_list_items_streaming_matches_full() { - let cfg = test_config(); + #[tokio::test] + async fn loose_vs_tight_list_items_streaming_matches_full() { + let cfg = test_config().await; // Deltas extracted from the session log around 2025-08-27T00:33:18.216Z let deltas = vec![ "\n\n", @@ -665,8 +664,8 @@ mod tests { } // Targeted tests derived from fuzz findings. Each asserts streamed == full render. - fn assert_streamed_equals_full(deltas: &[&str]) { - let cfg = test_config(); + async fn assert_streamed_equals_full(deltas: &[&str]) { + let cfg = test_config().await; let streamed = simulate_stream_markdown_for_tests(deltas, true, &cfg); let streamed_strs = lines_to_plain_strings(&streamed); let full: String = deltas.iter().copied().collect(); @@ -676,28 +675,31 @@ mod tests { assert_eq!(streamed_strs, rendered_strs, "full:\n---\n{full}\n---"); } - #[test] - fn fuzz_class_bullet_duplication_variant_1() { + #[tokio::test] + async fn fuzz_class_bullet_duplication_variant_1() { assert_streamed_equals_full(&[ "aph.\n- let one\n- bull", "et two\n\n second paragraph \n", - ]); + ]) + .await; } - #[test] - fn fuzz_class_bullet_duplication_variant_2() { + #[tokio::test] + async fn fuzz_class_bullet_duplication_variant_2() { assert_streamed_equals_full(&[ "- e\n c", "e\n- bullet two\n\n second paragraph in bullet two\n", - ]); + ]) + .await; } - #[test] - fn streaming_html_block_then_text_matches_full() { + #[tokio::test] + async fn streaming_html_block_then_text_matches_full() { assert_streamed_equals_full(&[ "HTML block:\n", "
inline block
\n", "more stuff\n", - ]); + ]) + .await; } } diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index a7c45d1f..372f296a 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -91,15 +91,14 @@ mod tests { use codex_core::config::Config; use codex_core::config::ConfigOverrides; - fn test_config() -> Config { + async fn test_config() -> Config { let overrides = ConfigOverrides { cwd: std::env::current_dir().ok(), ..Default::default() }; - match Config::load_with_cli_overrides(vec![], overrides) { - Ok(c) => c, - Err(e) => panic!("load test config: {e}"), - } + Config::load_with_cli_overrides(vec![], overrides) + .await + .expect("load test config") } fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec { @@ -115,9 +114,9 @@ mod tests { .collect() } - #[test] - fn controller_loose_vs_tight_with_commit_ticks_matches_full() { - let cfg = test_config(); + #[tokio::test] + async fn controller_loose_vs_tight_with_commit_ticks_matches_full() { + let cfg = test_config().await; let mut ctrl = StreamController::new(cfg.clone(), None); let mut lines = Vec::new();