diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 309c671e..8f1762ca 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -506,6 +506,8 @@ version = "0.0.0" dependencies = [ "clap", "codex-core", + "serde", + "toml", ] [[package]] @@ -634,6 +636,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "toml", "tracing", "tracing-subscriber", ] diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index c09cee02..deacca5f 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use codex_common::CliConfigOverrides; use codex_common::SandboxPermissionOption; use codex_core::config::Config; use codex_core::config::ConfigOverrides; @@ -20,12 +21,14 @@ pub async fn run_command_under_seatbelt( let SeatbeltCommand { full_auto, sandbox, + config_overrides, command, } = command; run_command_under_sandbox( full_auto, sandbox, command, + config_overrides, codex_linux_sandbox_exe, SandboxType::Seatbelt, ) @@ -39,12 +42,14 @@ pub async fn run_command_under_landlock( let LandlockCommand { full_auto, sandbox, + config_overrides, command, } = command; run_command_under_sandbox( full_auto, sandbox, command, + config_overrides, codex_linux_sandbox_exe, SandboxType::Landlock, ) @@ -60,16 +65,22 @@ async fn run_command_under_sandbox( full_auto: bool, sandbox: SandboxPermissionOption, command: Vec, + config_overrides: CliConfigOverrides, codex_linux_sandbox_exe: Option, sandbox_type: SandboxType, ) -> anyhow::Result<()> { let sandbox_policy = create_sandbox_policy(full_auto, sandbox); let cwd = std::env::current_dir()?; - let config = Config::load_with_overrides(ConfigOverrides { - sandbox_policy: Some(sandbox_policy), - codex_linux_sandbox_exe, - ..Default::default() - })?; + let config = Config::load_with_cli_overrides( + config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?, + ConfigOverrides { + sandbox_policy: Some(sandbox_policy), + codex_linux_sandbox_exe, + ..Default::default() + }, + )?; let stdio_policy = StdioPolicy::Inherit; let env = create_env(&config.shell_environment_policy); diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index bf85c98c..0730a919 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -3,6 +3,7 @@ mod exit_status; pub mod proto; use clap::Parser; +use codex_common::CliConfigOverrides; use codex_common::SandboxPermissionOption; #[derive(Debug, Parser)] @@ -14,6 +15,9 @@ pub struct SeatbeltCommand { #[clap(flatten)] pub sandbox: SandboxPermissionOption, + #[clap(skip)] + pub config_overrides: CliConfigOverrides, + /// Full command args to run under seatbelt. #[arg(trailing_var_arg = true)] pub command: Vec, @@ -28,6 +32,9 @@ pub struct LandlockCommand { #[clap(flatten)] pub sandbox: SandboxPermissionOption, + #[clap(skip)] + pub config_overrides: CliConfigOverrides, + /// Full command args to run under landlock. #[arg(trailing_var_arg = true)] pub command: Vec, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8f44962e..1c362d2a 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -2,6 +2,7 @@ use clap::Parser; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; use codex_cli::proto; +use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; use codex_tui::Cli as TuiCli; use std::path::PathBuf; @@ -19,6 +20,9 @@ use crate::proto::ProtoCli; subcommand_negates_reqs = true )] struct MultitoolCli { + #[clap(flatten)] + pub config_overrides: CliConfigOverrides, + #[clap(flatten)] interactive: TuiCli, @@ -73,28 +77,34 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() match cli.subcommand { None => { - codex_tui::run_main(cli.interactive, codex_linux_sandbox_exe)?; + let mut tui_cli = cli.interactive; + prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides); + codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?; } - Some(Subcommand::Exec(exec_cli)) => { + Some(Subcommand::Exec(mut exec_cli)) => { + prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides); codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?; } Some(Subcommand::Mcp) => { codex_mcp_server::run_main(codex_linux_sandbox_exe).await?; } - Some(Subcommand::Proto(proto_cli)) => { + Some(Subcommand::Proto(mut proto_cli)) => { + prepend_config_flags(&mut proto_cli.config_overrides, cli.config_overrides); proto::run_main(proto_cli).await?; } Some(Subcommand::Debug(debug_args)) => match debug_args.cmd { - DebugCommand::Seatbelt(seatbelt_command) => { + DebugCommand::Seatbelt(mut seatbelt_cli) => { + prepend_config_flags(&mut seatbelt_cli.config_overrides, cli.config_overrides); codex_cli::debug_sandbox::run_command_under_seatbelt( - seatbelt_command, + seatbelt_cli, codex_linux_sandbox_exe, ) .await?; } - DebugCommand::Landlock(landlock_command) => { + DebugCommand::Landlock(mut landlock_cli) => { + prepend_config_flags(&mut landlock_cli.config_overrides, cli.config_overrides); codex_cli::debug_sandbox::run_command_under_landlock( - landlock_command, + landlock_cli, codex_linux_sandbox_exe, ) .await?; @@ -104,3 +114,14 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Ok(()) } + +/// Prepend root-level overrides so they have lower precedence than +/// CLI-specific ones specified after the subcommand (if any). +fn prepend_config_flags( + subcommand_config_overrides: &mut CliConfigOverrides, + cli_config_overrides: CliConfigOverrides, +) { + subcommand_config_overrides + .raw_overrides + .splice(0..0, cli_config_overrides.raw_overrides); +} diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs index 6dbe049c..14869955 100644 --- a/codex-rs/cli/src/proto.rs +++ b/codex-rs/cli/src/proto.rs @@ -2,6 +2,7 @@ use std::io::IsTerminal; use std::sync::Arc; use clap::Parser; +use codex_common::CliConfigOverrides; use codex_core::Codex; use codex_core::config::Config; use codex_core::config::ConfigOverrides; @@ -13,9 +14,12 @@ use tracing::error; use tracing::info; #[derive(Debug, Parser)] -pub struct ProtoCli {} +pub struct ProtoCli { + #[clap(skip)] + pub config_overrides: CliConfigOverrides, +} -pub async fn run_main(_opts: ProtoCli) -> anyhow::Result<()> { +pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> { if std::io::stdin().is_terminal() { anyhow::bail!("Protocol mode expects stdin to be a pipe, not a terminal"); } @@ -24,7 +28,12 @@ pub async fn run_main(_opts: ProtoCli) -> anyhow::Result<()> { .with_writer(std::io::stderr) .init(); - let config = Config::load_with_overrides(ConfigOverrides::default())?; + let ProtoCli { config_overrides } = opts; + let overrides_vec = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + + let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?; let ctrl_c = notify_on_sigint(); let (codex, _init_id) = Codex::spawn(config, ctrl_c.clone()).await?; let codex = Arc::new(codex); diff --git a/codex-rs/common/Cargo.toml b/codex-rs/common/Cargo.toml index 95e4a531..b4b658da 100644 --- a/codex-rs/common/Cargo.toml +++ b/codex-rs/common/Cargo.toml @@ -9,8 +9,10 @@ workspace = true [dependencies] clap = { version = "4", features = ["derive", "wrap_help"], optional = true } codex-core = { path = "../core" } +toml = { version = "0.8", optional = true } +serde = { version = "1", optional = true } [features] # Separate feature so that `clap` is not a mandatory dependency. -cli = ["clap"] +cli = ["clap", "toml", "serde"] elapsed = [] diff --git a/codex-rs/common/src/config_override.rs b/codex-rs/common/src/config_override.rs new file mode 100644 index 00000000..bd2c0369 --- /dev/null +++ b/codex-rs/common/src/config_override.rs @@ -0,0 +1,170 @@ +//! Support for `-c key=value` overrides shared across Codex CLI tools. +//! +//! This module provides a [`CliConfigOverrides`] struct that can be embedded +//! into a `clap`-derived CLI struct using `#[clap(flatten)]`. Each occurrence +//! of `-c key=value` (or `--config key=value`) will be collected as a raw +//! string. Helper methods are provided to convert the raw strings into +//! key/value pairs as well as to apply them onto a mutable +//! `serde_json::Value` representing the configuration tree. + +use clap::ArgAction; +use clap::Parser; +use serde::de::Error as SerdeError; +use toml::Value; + +/// CLI option that captures arbitrary configuration overrides specified as +/// `-c key=value`. It intentionally keeps both halves **unparsed** so that the +/// calling code can decide how to interpret the right-hand side. +#[derive(Parser, Debug, Default, Clone)] +pub struct CliConfigOverrides { + /// Override a configuration value that would otherwise be loaded from + /// `~/.codex/config.toml`. Use a dotted path (`foo.bar.baz`) to override + /// nested values. The `value` portion is parsed as JSON. If it fails to + /// parse as JSON, the raw string is used as a literal. + /// + /// Examples: + /// - `-c model="o4-mini"` + /// - `-c 'sandbox_permissions=["disk-full-read-access"]'` + /// - `-c shell_environment_policy.inherit=all` + #[arg( + short = 'c', + long = "config", + value_name = "key=value", + action = ArgAction::Append, + global = true, + )] + pub raw_overrides: Vec, +} + +impl CliConfigOverrides { + /// Parse the raw strings captured from the CLI into a list of `(path, + /// value)` tuples where `value` is a `serde_json::Value`. + pub fn parse_overrides(&self) -> Result, String> { + self.raw_overrides + .iter() + .map(|s| { + // Only split on the *first* '=' so values are free to contain + // the character. + let mut parts = s.splitn(2, '='); + let key = match parts.next() { + Some(k) => k.trim(), + None => return Err("Override missing key".to_string()), + }; + let value_str = parts + .next() + .ok_or_else(|| format!("Invalid override (missing '='): {s}"))? + .trim(); + + if key.is_empty() { + return Err(format!("Empty key in override: {s}")); + } + + // Attempt to parse as JSON. If that fails, treat it as a raw + // string. This allows convenient usage such as + // `-c model=o4-mini` without the quotes. + let value: Value = match parse_toml_value(value_str) { + Ok(v) => v, + Err(_) => Value::String(value_str.to_string()), + }; + + Ok((key.to_string(), value)) + }) + .collect() + } + + /// Apply all parsed overrides onto `target`. Intermediate objects will be + /// created as necessary. Values located at the destination path will be + /// replaced. + pub fn apply_on_value(&self, target: &mut Value) -> Result<(), String> { + let overrides = self.parse_overrides()?; + for (path, value) in overrides { + apply_single_override(target, &path, value); + } + Ok(()) + } +} + +/// Apply a single override onto `root`, creating intermediate objects as +/// necessary. +fn apply_single_override(root: &mut Value, path: &str, value: Value) { + use toml::value::Table; + + let parts: Vec<&str> = path.split('.').collect(); + let mut current = root; + + for (i, part) in parts.iter().enumerate() { + let is_last = i == parts.len() - 1; + + if is_last { + match current { + Value::Table(tbl) => { + tbl.insert((*part).to_string(), value); + } + _ => { + let mut tbl = Table::new(); + tbl.insert((*part).to_string(), value); + *current = Value::Table(tbl); + } + } + return; + } + + // Traverse or create intermediate table. + match current { + Value::Table(tbl) => { + current = tbl + .entry((*part).to_string()) + .or_insert_with(|| Value::Table(Table::new())); + } + _ => { + *current = Value::Table(Table::new()); + if let Value::Table(tbl) = current { + current = tbl + .entry((*part).to_string()) + .or_insert_with(|| Value::Table(Table::new())); + } + } + } + } +} + +fn parse_toml_value(raw: &str) -> Result { + let wrapped = format!("_x_ = {raw}"); + let table: toml::Table = toml::from_str(&wrapped)?; + table + .get("_x_") + .cloned() + .ok_or_else(|| SerdeError::custom("missing sentinel key")) +} + +#[cfg(all(test, feature = "cli"))] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn parses_basic_scalar() { + let v = parse_toml_value("42").expect("parse"); + assert_eq!(v.as_integer(), Some(42)); + } + + #[test] + fn fails_on_unquoted_string() { + assert!(parse_toml_value("hello").is_err()); + } + + #[test] + fn parses_array() { + let v = parse_toml_value("[1, 2, 3]").expect("parse"); + let arr = v.as_array().expect("array"); + assert_eq!(arr.len(), 3); + } + + #[test] + fn parses_inline_table() { + let v = parse_toml_value("{a = 1, b = 2}").expect("parse"); + let tbl = v.as_table().expect("table"); + assert_eq!(tbl.get("a").unwrap().as_integer(), Some(1)); + assert_eq!(tbl.get("b").unwrap().as_integer(), Some(2)); + } +} diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs index 25337188..c2283640 100644 --- a/codex-rs/common/src/lib.rs +++ b/codex-rs/common/src/lib.rs @@ -8,3 +8,9 @@ pub mod elapsed; pub use approval_mode_cli_arg::ApprovalModeCliArg; #[cfg(feature = "cli")] pub use approval_mode_cli_arg::SandboxPermissionOption; + +#[cfg(any(feature = "cli", test))] +mod config_override; + +#[cfg(feature = "cli")] +pub use config_override::CliConfigOverrides; diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index d643d006..b6871da1 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -16,6 +16,7 @@ use serde::Deserialize; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; +use toml::Value as TomlValue; /// 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 @@ -108,6 +109,108 @@ pub struct Config { pub codex_linux_sandbox_exe: Option, } +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( + 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)?; + + // 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) + } +} + +/// Read `CODEX_HOME/config.toml` and return it as a generic TOML value. Returns +/// an empty TOML table when the file does not exist. +fn load_config_as_toml(codex_home: &Path) -> std::io::Result { + let config_path = codex_home.join("config.toml"); + 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) + } + } +} + +/// Apply a single dotted-path override onto a TOML value. +fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) { + use toml::value::Table; + + let segments: Vec<&str> = path.split('.').collect(); + let mut current = root; + + for (idx, segment) in segments.iter().enumerate() { + let is_last = idx == segments.len() - 1; + + if is_last { + match current { + TomlValue::Table(table) => { + table.insert(segment.to_string(), value); + } + _ => { + let mut table = Table::new(); + table.insert(segment.to_string(), value); + *current = TomlValue::Table(table); + } + } + return; + } + + // Traverse or create intermediate object. + match current { + TomlValue::Table(table) => { + current = table + .entry(segment.to_string()) + .or_insert_with(|| TomlValue::Table(Table::new())); + } + _ => { + *current = TomlValue::Table(Table::new()); + if let TomlValue::Table(tbl) = current { + current = tbl + .entry(segment.to_string()) + .or_insert_with(|| TomlValue::Table(Table::new())); + } + } + } + } +} + /// Base config deserialized from ~/.codex/config.toml. #[derive(Deserialize, Debug, Clone, Default)] pub struct ConfigToml { @@ -171,29 +274,6 @@ pub struct ConfigToml { pub tui: Option, } -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(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}"); - 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(Self::default()) - } - Err(e) => { - tracing::error!("Failed to read config.toml: {e}"); - Err(e) - } - } - } -} - fn deserialize_sandbox_permissions<'de, D>( deserializer: D, ) -> Result>, D::Error> @@ -227,28 +307,12 @@ pub struct ConfigOverrides { pub cwd: Option, pub approval_policy: Option, pub sandbox_policy: Option, - pub disable_response_storage: Option, pub model_provider: Option, pub config_profile: Option, pub codex_linux_sandbox_exe: Option, } impl Config { - /// Load configuration, optionally applying overrides (CLI flags). Merges - /// ~/.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 { - // 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:?}"); - - Self::load_from_base_config_with_overrides(cfg, overrides, codex_home) - } - /// 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( @@ -264,7 +328,6 @@ impl Config { cwd, approval_policy, sandbox_policy, - disable_response_storage, model_provider, config_profile: config_profile_key, codex_linux_sandbox_exe, @@ -356,8 +419,8 @@ impl Config { .unwrap_or_else(AskForApproval::default), sandbox_policy, shell_environment_policy, - disable_response_storage: disable_response_storage - .or(config_profile.disable_response_storage) + disable_response_storage: config_profile + .disable_response_storage .or(cfg.disable_response_storage) .unwrap_or(false), notify: cfg.notify, diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 6696f76f..d89b09f2 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -89,7 +89,7 @@ pub struct Tui { } #[derive(Deserialize, Debug, Clone, PartialEq, Default)] - +#[serde(rename_all = "kebab-case")] pub enum ShellEnvironmentPolicyInherit { /// "Core" environment variables for the platform. On UNIX, this would /// include HOME, LOGNAME, PATH, SHELL, and USER, among others. diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 4a3d493a..1c2a9eb8 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -1,5 +1,6 @@ use clap::Parser; use clap::ValueEnum; +use codex_common::CliConfigOverrides; use codex_common::SandboxPermissionOption; use std::path::PathBuf; @@ -33,9 +34,8 @@ pub struct Cli { #[arg(long = "skip-git-repo-check", default_value_t = false)] pub skip_git_repo_check: bool, - /// Disable server‑side response storage (sends the full conversation context with every request) - #[arg(long = "disable-response-storage", default_value_t = false)] - pub disable_response_storage: bool, + #[clap(skip)] + pub config_overrides: CliConfigOverrides, /// Specifies color settings for use in the output. #[arg(long = "color", value_enum, default_value_t = Color::Auto)] diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index dbf01f02..8c94fe5d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -34,10 +34,10 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any sandbox, cwd, skip_git_repo_check, - disable_response_storage, color, last_message_file, prompt, + config_overrides, } = cli; let (stdout_with_ansi, stderr_with_ansi) = match color { @@ -63,16 +63,20 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any // the user for approval. approval_policy: Some(AskForApproval::Never), sandbox_policy, - disable_response_storage: if disable_response_storage { - Some(true) - } else { - None - }, cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)), model_provider: None, codex_linux_sandbox_exe, }; - let config = Config::load_with_overrides(overrides)?; + // Parse `-c` overrides. + let cli_kv_overrides = match config_overrides.parse_overrides() { + Ok(v) => v, + Err(e) => { + eprintln!("Error parsing -c overrides: {e}"); + std::process::exit(1); + } + }; + + let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?; // Print the effective configuration so users can see what Codex is using. print_config_summary(&config, stdout_with_ansi); diff --git a/codex-rs/exec/src/main.rs b/codex-rs/exec/src/main.rs index 17aa5377..3a8e1f94 100644 --- a/codex-rs/exec/src/main.rs +++ b/codex-rs/exec/src/main.rs @@ -10,13 +10,30 @@ //! This allows us to ship a completely separate set of functionality as part //! of the `codex-exec` binary. use clap::Parser; +use codex_common::CliConfigOverrides; use codex_exec::Cli; use codex_exec::run_main; +#[derive(Parser, Debug)] +struct TopCli { + #[clap(flatten)] + config_overrides: CliConfigOverrides, + + #[clap(flatten)] + inner: Cli, +} + fn main() -> anyhow::Result<()> { codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move { - let cli = Cli::parse(); - run_main(cli, codex_linux_sandbox_exe).await?; + let top_cli = TopCli::parse(); + // Merge root-level overrides into inner CLI struct so downstream logic remains unchanged. + let mut inner = top_cli.inner; + inner + .config_overrides + .raw_overrides + .splice(0..0, top_cli.config_overrides.raw_overrides); + + run_main(inner, codex_linux_sandbox_exe).await?; Ok(()) }) } diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 968222c9..c3f11158 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -22,6 +22,7 @@ mcp-types = { path = "../mcp-types" } schemars = "0.8.22" serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } tokio = { version = "1", features = [ diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index d04a5c80..03e72344 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -1,15 +1,16 @@ //! Configuration object accepted by the `codex` MCP tool-call. -use std::path::PathBuf; - +use codex_core::protocol::AskForApproval; +use codex_core::protocol::SandboxPolicy; use mcp_types::Tool; use mcp_types::ToolInputSchema; use schemars::JsonSchema; use schemars::r#gen::SchemaSettings; use serde::Deserialize; +use std::collections::HashMap; +use std::path::PathBuf; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; +use crate::json_to_toml::json_to_toml; /// Client-supplied configuration for a `codex` tool-call. #[derive(Debug, Clone, Deserialize, JsonSchema)] @@ -41,12 +42,10 @@ pub(crate) struct CodexToolCallParam { #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox_permissions: Option>, - /// Disable server-side response storage. + /// Individual config settings that will override what is in + /// CODEX_HOME/config.toml. #[serde(default, skip_serializing_if = "Option::is_none")] - pub disable_response_storage: Option, - // Custom system instructions. - // #[serde(default, skip_serializing_if = "Option::is_none")] - // pub instructions: Option, + pub config: Option>, } // Create custom enums for use with `CodexToolCallApprovalPolicy` where we @@ -155,7 +154,7 @@ impl CodexToolCallParam { cwd, approval_policy, sandbox_permissions, - disable_response_storage, + config: cli_overrides, } = self; let sandbox_policy = sandbox_permissions.map(|perms| { SandboxPolicy::from(perms.into_iter().map(Into::into).collect::>()) @@ -168,12 +167,17 @@ impl CodexToolCallParam { cwd: cwd.map(PathBuf::from), approval_policy: approval_policy.map(Into::into), sandbox_policy, - disable_response_storage, model_provider: None, codex_linux_sandbox_exe, }; - let cfg = codex_core::config::Config::load_with_overrides(overrides)?; + let cli_overrides = cli_overrides + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, json_to_toml(v))) + .collect(); + + let cfg = codex_core::config::Config::load_with_cli_overrides(cli_overrides, overrides)?; Ok((prompt, cfg)) } @@ -216,14 +220,15 @@ mod tests { ], "type": "string" }, + "config": { + "description": "Individual config settings that will override what is in CODEX_HOME/config.toml.", + "additionalProperties": true, + "type": "object" + }, "cwd": { "description": "Working directory for the session. If relative, it is resolved against the server process's current working directory.", "type": "string" }, - "disable-response-storage": { - "description": "Disable server-side response storage.", - "type": "boolean" - }, "model": { "description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\")", "type": "string" diff --git a/codex-rs/mcp-server/src/json_to_toml.rs b/codex-rs/mcp-server/src/json_to_toml.rs new file mode 100644 index 00000000..ae33382a --- /dev/null +++ b/codex-rs/mcp-server/src/json_to_toml.rs @@ -0,0 +1,84 @@ +use serde_json::Value as JsonValue; +use toml::Value as TomlValue; + +/// Convert a `serde_json::Value` into a semantically equivalent `toml::Value`. +pub(crate) fn json_to_toml(v: JsonValue) -> TomlValue { + match v { + JsonValue::Null => TomlValue::String(String::new()), + JsonValue::Bool(b) => TomlValue::Boolean(b), + JsonValue::Number(n) => { + if let Some(i) = n.as_i64() { + TomlValue::Integer(i) + } else if let Some(f) = n.as_f64() { + TomlValue::Float(f) + } else { + TomlValue::String(n.to_string()) + } + } + JsonValue::String(s) => TomlValue::String(s), + JsonValue::Array(arr) => TomlValue::Array(arr.into_iter().map(json_to_toml).collect()), + JsonValue::Object(map) => { + let tbl = map + .into_iter() + .map(|(k, v)| (k, json_to_toml(v))) + .collect::(); + TomlValue::Table(tbl) + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn json_number_to_toml() { + let json_value = json!(123); + assert_eq!(TomlValue::Integer(123), json_to_toml(json_value)); + } + + #[test] + fn json_array_to_toml() { + let json_value = json!([true, 1]); + assert_eq!( + TomlValue::Array(vec![TomlValue::Boolean(true), TomlValue::Integer(1)]), + json_to_toml(json_value) + ); + } + + #[test] + fn json_bool_to_toml() { + let json_value = json!(false); + assert_eq!(TomlValue::Boolean(false), json_to_toml(json_value)); + } + + #[test] + fn json_float_to_toml() { + let json_value = json!(1.25); + assert_eq!(TomlValue::Float(1.25), json_to_toml(json_value)); + } + + #[test] + fn json_null_to_toml() { + let json_value = serde_json::Value::Null; + assert_eq!(TomlValue::String(String::new()), json_to_toml(json_value)); + } + + #[test] + fn json_object_nested() { + let json_value = json!({ "outer": { "inner": 2 } }); + let expected = { + let mut inner = toml::value::Table::new(); + inner.insert("inner".into(), TomlValue::Integer(2)); + + let mut outer = toml::value::Table::new(); + outer.insert("outer".into(), TomlValue::Table(inner)); + TomlValue::Table(outer) + }; + + assert_eq!(json_to_toml(json_value), expected); + } +} diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 0f29eb78..b2a7797f 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -16,6 +16,7 @@ use tracing::info; mod codex_tool_config; mod codex_tool_runner; +mod json_to_toml; mod message_processor; use crate::message_processor::MessageProcessor; diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index f077d267..4abd6841 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -1,5 +1,6 @@ use clap::Parser; use codex_common::ApprovalModeCliArg; +use codex_common::CliConfigOverrides; use codex_common::SandboxPermissionOption; use std::path::PathBuf; @@ -40,7 +41,6 @@ pub struct Cli { #[arg(long = "skip-git-repo-check", default_value_t = false)] pub skip_git_repo_check: bool, - /// Disable server‑side response storage (sends the full conversation context with every request) - #[arg(long = "disable-response-storage", default_value_t = false)] - pub disable_response_storage: bool, + #[clap(skip)] + pub config_overrides: CliConfigOverrides, } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 4ab68724..1ddd79cf 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -54,18 +54,23 @@ pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> std::io:: model: cli.model.clone(), approval_policy, sandbox_policy, - disable_response_storage: if cli.disable_response_storage { - Some(true) - } else { - None - }, cwd: cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)), model_provider: None, config_profile: cli.config_profile.clone(), codex_linux_sandbox_exe, }; + // Parse `-c` overrides from the CLI. + let cli_kv_overrides = match cli.config_overrides.parse_overrides() { + Ok(v) => v, + #[allow(clippy::print_stderr)] + Err(e) => { + eprintln!("Error parsing -c overrides: {e}"); + std::process::exit(1); + } + }; + #[allow(clippy::print_stderr)] - match Config::load_with_overrides(overrides) { + match Config::load_with_cli_overrides(cli_kv_overrides, overrides) { Ok(config) => config, Err(err) => { eprintln!("Error loading configuration: {err}"); diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 7e55f2af..7fcc9445 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -1,11 +1,26 @@ use clap::Parser; +use codex_common::CliConfigOverrides; use codex_tui::Cli; use codex_tui::run_main; +#[derive(Parser, Debug)] +struct TopCli { + #[clap(flatten)] + config_overrides: CliConfigOverrides, + + #[clap(flatten)] + inner: Cli, +} + fn main() -> anyhow::Result<()> { codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move { - let cli = Cli::parse(); - run_main(cli, codex_linux_sandbox_exe)?; + let top_cli = TopCli::parse(); + let mut inner = top_cli.inner; + inner + .config_overrides + .raw_overrides + .splice(0..0, top_cli.config_overrides.raw_overrides); + run_main(inner, codex_linux_sandbox_exe)?; Ok(()) }) }