diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index eefd1231828..526558250c3 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -13,6 +13,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use anyhow::bail; +use clap::ArgAction; use clap::Parser; use clap::Subcommand; use codex_app_server_protocol::AddConversationListenerParams; @@ -67,6 +68,19 @@ struct Cli { #[arg(long, env = "CODEX_BIN", default_value = "codex")] codex_bin: String, + /// Forwarded to the `codex` CLI as `--config key=value`. Repeatable. + /// + /// Example: + /// `--config 'model_providers.mock.base_url="http://localhost:4010/v2"'` + #[arg( + short = 'c', + long = "config", + value_name = "key=value", + action = ArgAction::Append, + global = true + )] + config_overrides: Vec, + #[command(subcommand)] command: CliCommand, } @@ -121,30 +135,43 @@ enum CliCommand { } fn main() -> Result<()> { - let Cli { codex_bin, command } = Cli::parse(); + let Cli { + codex_bin, + config_overrides, + command, + } = Cli::parse(); match command { - CliCommand::SendMessage { user_message } => send_message(codex_bin, user_message), - CliCommand::SendMessageV2 { user_message } => send_message_v2(codex_bin, user_message), + CliCommand::SendMessage { user_message } => { + send_message(&codex_bin, &config_overrides, user_message) + } + CliCommand::SendMessageV2 { user_message } => { + send_message_v2(&codex_bin, &config_overrides, user_message) + } CliCommand::TriggerCmdApproval { user_message } => { - trigger_cmd_approval(codex_bin, user_message) + trigger_cmd_approval(&codex_bin, &config_overrides, user_message) } CliCommand::TriggerPatchApproval { user_message } => { - trigger_patch_approval(codex_bin, user_message) + trigger_patch_approval(&codex_bin, &config_overrides, user_message) } - CliCommand::NoTriggerCmdApproval => no_trigger_cmd_approval(codex_bin), + CliCommand::NoTriggerCmdApproval => no_trigger_cmd_approval(&codex_bin, &config_overrides), CliCommand::SendFollowUpV2 { first_message, follow_up_message, - } => send_follow_up_v2(codex_bin, first_message, follow_up_message), - CliCommand::TestLogin => test_login(codex_bin), - CliCommand::GetAccountRateLimits => get_account_rate_limits(codex_bin), - CliCommand::ModelList => model_list(codex_bin), + } => send_follow_up_v2( + &codex_bin, + &config_overrides, + first_message, + follow_up_message, + ), + CliCommand::TestLogin => test_login(&codex_bin, &config_overrides), + CliCommand::GetAccountRateLimits => get_account_rate_limits(&codex_bin, &config_overrides), + CliCommand::ModelList => model_list(&codex_bin, &config_overrides), } } -fn send_message(codex_bin: String, user_message: String) -> Result<()> { - let mut client = CodexClient::spawn(codex_bin)?; +fn send_message(codex_bin: &str, config_overrides: &[String], user_message: String) -> Result<()> { + let mut client = CodexClient::spawn(codex_bin, config_overrides)?; let initialize = client.initialize()?; println!("< initialize response: {initialize:?}"); @@ -165,46 +192,61 @@ fn send_message(codex_bin: String, user_message: String) -> Result<()> { Ok(()) } -fn send_message_v2(codex_bin: String, user_message: String) -> Result<()> { - send_message_v2_with_policies(codex_bin, user_message, None, None) +fn send_message_v2( + codex_bin: &str, + config_overrides: &[String], + user_message: String, +) -> Result<()> { + send_message_v2_with_policies(codex_bin, config_overrides, user_message, None, None) } -fn trigger_cmd_approval(codex_bin: String, user_message: Option) -> Result<()> { +fn trigger_cmd_approval( + codex_bin: &str, + config_overrides: &[String], + user_message: Option, +) -> Result<()> { let default_prompt = "Run `touch /tmp/should-trigger-approval` so I can confirm the file exists."; let message = user_message.unwrap_or_else(|| default_prompt.to_string()); send_message_v2_with_policies( codex_bin, + config_overrides, message, Some(AskForApproval::OnRequest), Some(SandboxPolicy::ReadOnly), ) } -fn trigger_patch_approval(codex_bin: String, user_message: Option) -> Result<()> { +fn trigger_patch_approval( + codex_bin: &str, + config_overrides: &[String], + user_message: Option, +) -> Result<()> { let default_prompt = "Create a file named APPROVAL_DEMO.txt containing a short hello message using apply_patch."; let message = user_message.unwrap_or_else(|| default_prompt.to_string()); send_message_v2_with_policies( codex_bin, + config_overrides, message, Some(AskForApproval::OnRequest), Some(SandboxPolicy::ReadOnly), ) } -fn no_trigger_cmd_approval(codex_bin: String) -> Result<()> { +fn no_trigger_cmd_approval(codex_bin: &str, config_overrides: &[String]) -> Result<()> { let prompt = "Run `touch should_not_trigger_approval.txt`"; - send_message_v2_with_policies(codex_bin, prompt.to_string(), None, None) + send_message_v2_with_policies(codex_bin, config_overrides, prompt.to_string(), None, None) } fn send_message_v2_with_policies( - codex_bin: String, + codex_bin: &str, + config_overrides: &[String], user_message: String, approval_policy: Option, sandbox_policy: Option, ) -> Result<()> { - let mut client = CodexClient::spawn(codex_bin)?; + let mut client = CodexClient::spawn(codex_bin, config_overrides)?; let initialize = client.initialize()?; println!("< initialize response: {initialize:?}"); @@ -228,11 +270,12 @@ fn send_message_v2_with_policies( } fn send_follow_up_v2( - codex_bin: String, + codex_bin: &str, + config_overrides: &[String], first_message: String, follow_up_message: String, ) -> Result<()> { - let mut client = CodexClient::spawn(codex_bin)?; + let mut client = CodexClient::spawn(codex_bin, config_overrides)?; let initialize = client.initialize()?; println!("< initialize response: {initialize:?}"); @@ -265,8 +308,8 @@ fn send_follow_up_v2( Ok(()) } -fn test_login(codex_bin: String) -> Result<()> { - let mut client = CodexClient::spawn(codex_bin)?; +fn test_login(codex_bin: &str, config_overrides: &[String]) -> Result<()> { + let mut client = CodexClient::spawn(codex_bin, config_overrides)?; let initialize = client.initialize()?; println!("< initialize response: {initialize:?}"); @@ -295,8 +338,8 @@ fn test_login(codex_bin: String) -> Result<()> { } } -fn get_account_rate_limits(codex_bin: String) -> Result<()> { - let mut client = CodexClient::spawn(codex_bin)?; +fn get_account_rate_limits(codex_bin: &str, config_overrides: &[String]) -> Result<()> { + let mut client = CodexClient::spawn(codex_bin, config_overrides)?; let initialize = client.initialize()?; println!("< initialize response: {initialize:?}"); @@ -307,8 +350,8 @@ fn get_account_rate_limits(codex_bin: String) -> Result<()> { Ok(()) } -fn model_list(codex_bin: String) -> Result<()> { - let mut client = CodexClient::spawn(codex_bin)?; +fn model_list(codex_bin: &str, config_overrides: &[String]) -> Result<()> { + let mut client = CodexClient::spawn(codex_bin, config_overrides)?; let initialize = client.initialize()?; println!("< initialize response: {initialize:?}"); @@ -327,8 +370,12 @@ struct CodexClient { } impl CodexClient { - fn spawn(codex_bin: String) -> Result { - let mut codex_app_server = Command::new(&codex_bin) + fn spawn(codex_bin: &str, config_overrides: &[String]) -> Result { + let mut cmd = Command::new(codex_bin); + for override_kv in config_overrides { + cmd.arg("--config").arg(override_kv); + } + let mut codex_app_server = cmd .arg("app-server") .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c1166d95088..164a84e4e96 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1246,14 +1246,14 @@ impl CodexMessageProcessor { cwd, approval_policy, sandbox: sandbox_mode, - config: cli_overrides, + config: request_overrides, base_instructions, developer_instructions, compact_prompt, include_apply_patch_tool, } = params; - let overrides = ConfigOverrides { + let typesafe_overrides = ConfigOverrides { model, config_profile: profile, cwd: cwd.clone().map(PathBuf::from), @@ -1270,15 +1270,21 @@ impl CodexMessageProcessor { // Persist windows sandbox feature. // TODO: persist default config in general. - let mut cli_overrides = cli_overrides.unwrap_or_default(); + let mut request_overrides = request_overrides.unwrap_or_default(); if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { - cli_overrides.insert( + request_overrides.insert( "features.experimental_windows_sandbox".to_string(), serde_json::json!(true), ); } - let config = match derive_config_from_params(overrides, Some(cli_overrides)).await { + let config = match derive_config_from_params( + &self.cli_overrides, + Some(request_overrides), + typesafe_overrides, + ) + .await + { Ok(config) => config, Err(err) => { let error = JSONRPCErrorError { @@ -1318,7 +1324,7 @@ impl CodexMessageProcessor { } async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) { - let overrides = self.build_thread_config_overrides( + let typesafe_overrides = self.build_thread_config_overrides( params.model, params.model_provider, params.cwd, @@ -1328,18 +1334,21 @@ impl CodexMessageProcessor { params.developer_instructions, ); - let config = match derive_config_from_params(overrides, params.config).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; - } - }; + let config = + match derive_config_from_params(&self.cli_overrides, params.config, typesafe_overrides) + .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(new_conv) => { @@ -1543,7 +1552,7 @@ impl CodexMessageProcessor { cwd, approval_policy, sandbox, - config: cli_overrides, + config: request_overrides, base_instructions, developer_instructions, } = params; @@ -1553,12 +1562,12 @@ impl CodexMessageProcessor { || cwd.is_some() || approval_policy.is_some() || sandbox.is_some() - || cli_overrides.is_some() + || request_overrides.is_some() || base_instructions.is_some() || developer_instructions.is_some(); let config = if overrides_requested { - let overrides = self.build_thread_config_overrides( + let typesafe_overrides = self.build_thread_config_overrides( model, model_provider, cwd, @@ -1567,7 +1576,13 @@ impl CodexMessageProcessor { base_instructions, developer_instructions, ); - match derive_config_from_params(overrides, cli_overrides).await { + match derive_config_from_params( + &self.cli_overrides, + request_overrides, + typesafe_overrides, + ) + .await + { Ok(config) => config, Err(err) => { let error = JSONRPCErrorError { @@ -2197,7 +2212,7 @@ impl CodexMessageProcessor { cwd, approval_policy, sandbox: sandbox_mode, - config: cli_overrides, + config: request_overrides, base_instructions, developer_instructions, compact_prompt, @@ -2205,15 +2220,15 @@ impl CodexMessageProcessor { } = overrides; // Persist windows sandbox feature. - let mut cli_overrides = cli_overrides.unwrap_or_default(); + let mut request_overrides = request_overrides.unwrap_or_default(); if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { - cli_overrides.insert( + request_overrides.insert( "features.experimental_windows_sandbox".to_string(), serde_json::json!(true), ); } - let overrides = ConfigOverrides { + let typesafe_overrides = ConfigOverrides { model, config_profile: profile, cwd: cwd.map(PathBuf::from), @@ -2228,7 +2243,12 @@ impl CodexMessageProcessor { ..Default::default() }; - derive_config_from_params(overrides, Some(cli_overrides)).await + derive_config_from_params( + &self.cli_overrides, + Some(request_overrides), + typesafe_overrides, + ) + .await } None => Ok(self.config.as_ref().clone()), }; @@ -3341,17 +3361,34 @@ fn errors_to_info( .collect() } +/// Derive the effective [`Config`] by layering three override sources. +/// +/// Precedence (lowest to highest): +/// - `cli_overrides`: process-wide startup `--config` flags. +/// - `request_overrides`: per-request dotted-path overrides (`params.config`), converted JSON->TOML. +/// - `typesafe_overrides`: Request objects such as `NewConversationParams` and +/// `ThreadStartParams` support a limited set of _explicit_ config overrides, so +/// `typesafe_overrides` is a `ConfigOverrides` derived from the respective request object. +/// Because the overrides are defined explicitly in the `*Params`, this takes priority over +/// the more general "bag of config options" provided by `cli_overrides` and `request_overrides`. async fn derive_config_from_params( - overrides: ConfigOverrides, - cli_overrides: Option>, + cli_overrides: &[(String, TomlValue)], + request_overrides: Option>, + typesafe_overrides: ConfigOverrides, ) -> std::io::Result { - let cli_overrides = cli_overrides - .unwrap_or_default() - .into_iter() - .map(|(k, v)| (k, json_to_toml(v))) - .collect(); + let merged_cli_overrides = cli_overrides + .iter() + .cloned() + .chain( + request_overrides + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, json_to_toml(v))), + ) + .collect::>(); - Config::load_with_cli_overrides_and_harness_overrides(cli_overrides, overrides).await + Config::load_with_cli_overrides_and_harness_overrides(merged_cli_overrides, typesafe_overrides) + .await } async fn read_summary_from_rollout(