diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 849e8b104b00..c8991e33e14e 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -717,6 +717,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "exec", )?; + exec_cli + .shared + .inherit_exec_root_options(&interactive.shared); prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), @@ -1242,6 +1245,7 @@ async fn run_debug_prompt_input_command( interactive: TuiCli, arg0_paths: Arg0DispatchPaths, ) -> anyhow::Result<()> { + let shared = interactive.shared.into_inner(); let mut cli_kv_overrides = root_config_overrides .parse_overrides() .map_err(anyhow::Error::msg)?; @@ -1252,38 +1256,38 @@ async fn run_debug_prompt_input_command( )); } - let approval_policy = if interactive.full_auto { + let approval_policy = if shared.full_auto { Some(AskForApproval::OnRequest) - } else if interactive.dangerously_bypass_approvals_and_sandbox { + } else if shared.dangerously_bypass_approvals_and_sandbox { Some(AskForApproval::Never) } else { interactive.approval_policy.map(Into::into) }; - let sandbox_mode = if interactive.full_auto { + let sandbox_mode = if shared.full_auto { Some(codex_protocol::config_types::SandboxMode::WorkspaceWrite) - } else if interactive.dangerously_bypass_approvals_and_sandbox { + } else if shared.dangerously_bypass_approvals_and_sandbox { Some(codex_protocol::config_types::SandboxMode::DangerFullAccess) } else { - interactive.sandbox_mode.map(Into::into) + shared.sandbox_mode.map(Into::into) }; let overrides = ConfigOverrides { - model: interactive.model, - config_profile: interactive.config_profile, + model: shared.model, + config_profile: shared.config_profile, approval_policy, sandbox_mode, - cwd: interactive.cwd, + cwd: shared.cwd, codex_self_exe: arg0_paths.codex_self_exe, codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe, main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe, - show_raw_agent_reasoning: interactive.oss.then_some(true), + show_raw_agent_reasoning: shared.oss.then_some(true), ephemeral: Some(true), - additional_writable_roots: interactive.add_dir, + additional_writable_roots: shared.add_dir, ..Default::default() }; let config = Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; - let mut input = interactive + let mut input = shared .images .into_iter() .chain(cmd.images) @@ -1554,40 +1558,24 @@ fn finalize_fork_interactive( /// root-level flags. Only overrides fields explicitly set on the subcommand-scoped /// CLI. Also appends `-c key=value` overrides with highest precedence. fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) { - if let Some(model) = subcommand_cli.model { - interactive.model = Some(model); - } - if subcommand_cli.oss { - interactive.oss = true; - } - if let Some(profile) = subcommand_cli.config_profile { - interactive.config_profile = Some(profile); - } - if let Some(sandbox) = subcommand_cli.sandbox_mode { - interactive.sandbox_mode = Some(sandbox); - } - if let Some(approval) = subcommand_cli.approval_policy { + let TuiCli { + shared, + approval_policy, + web_search, + prompt, + config_overrides, + .. + } = subcommand_cli; + interactive + .shared + .apply_subcommand_overrides(shared.into_inner()); + if let Some(approval) = approval_policy { interactive.approval_policy = Some(approval); } - if subcommand_cli.full_auto { - interactive.full_auto = true; - } - if subcommand_cli.dangerously_bypass_approvals_and_sandbox { - interactive.dangerously_bypass_approvals_and_sandbox = true; - } - if let Some(cwd) = subcommand_cli.cwd { - interactive.cwd = Some(cwd); - } - if subcommand_cli.web_search { + if web_search { interactive.web_search = true; } - if !subcommand_cli.images.is_empty() { - interactive.images = subcommand_cli.images; - } - if !subcommand_cli.add_dir.is_empty() { - interactive.add_dir.extend(subcommand_cli.add_dir); - } - if let Some(prompt) = subcommand_cli.prompt { + if let Some(prompt) = prompt { // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); } @@ -1595,7 +1583,7 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) interactive .config_overrides .raw_overrides - .extend(subcommand_cli.config_overrides.raw_overrides); + .extend(config_overrides.raw_overrides); } fn print_completion(cmd: CompletionCommand) { @@ -1715,6 +1703,19 @@ mod tests { assert_eq!(args.prompt.as_deref(), Some("re-review")); } + #[test] + fn dangerous_bypass_conflicts_with_approval_policy() { + let err = MultitoolCli::try_parse_from([ + "codex", + "--dangerously-bypass-approvals-and-sandbox", + "--ask-for-approval", + "on-request", + ]) + .expect_err("conflicting permission flags should be rejected"); + + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + fn app_server_from_args(args: &[&str]) -> AppServerCommand { let cli = MultitoolCli::try_parse_from(args).expect("parse"); let Subcommand::AppServer(app_server) = cli.subcommand.expect("app-server present") else { diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 4d34e7f365ed..cbdf695562a1 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -3,6 +3,7 @@ use clap::FromArgMatches; use clap::Parser; use clap::ValueEnum; use codex_utils_cli::CliConfigOverrides; +use codex_utils_cli::SharedCliOptions; use std::path::PathBuf; #[derive(Parser, Debug)] @@ -15,65 +16,13 @@ pub struct Cli { #[command(subcommand)] pub command: Option, - /// Optional image(s) to attach to the initial prompt. - #[arg( - long = "image", - short = 'i', - value_name = "FILE", - value_delimiter = ',', - num_args = 1.. - )] - pub images: Vec, - - /// Model the agent should use. - #[arg(long, short = 'm', global = true)] - pub model: Option, - - /// Use open-source provider. - #[arg(long = "oss", default_value_t = false)] - pub oss: bool, - - /// Specify which local provider to use (lmstudio or ollama). - /// If not specified with --oss, will use config default or show selection. - #[arg(long = "local-provider")] - pub oss_provider: Option, - - /// Select the sandbox policy to use when executing model-generated shell - /// commands. - #[arg(long = "sandbox", short = 's', value_enum)] - pub sandbox_mode: Option, - - /// Configuration profile from config.toml to specify default options. - #[arg(long = "profile", short = 'p')] - pub config_profile: Option, - - /// Convenience alias for low-friction sandboxed automatic execution (--sandbox workspace-write). - #[arg(long = "full-auto", default_value_t = false, global = true)] - pub full_auto: bool, - - /// Skip all confirmation prompts and execute commands without sandboxing. - /// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed. - #[arg( - long = "dangerously-bypass-approvals-and-sandbox", - alias = "yolo", - default_value_t = false, - global = true, - conflicts_with = "full_auto" - )] - pub dangerously_bypass_approvals_and_sandbox: bool, - - /// Tell the agent to use the specified directory as its working root. - #[clap(long = "cd", short = 'C', value_name = "DIR")] - pub cwd: Option, + #[clap(flatten)] + pub shared: ExecSharedCliOptions, /// Allow running Codex outside a Git repository. #[arg(long = "skip-git-repo-check", global = true, default_value_t = false)] pub skip_git_repo_check: bool, - /// Additional directories that should be writable alongside the primary workspace. - #[arg(long = "add-dir", value_name = "DIR", value_hint = clap::ValueHint::DirPath)] - pub add_dir: Vec, - /// Run without persisting session files to disk. #[arg(long = "ephemeral", global = true, default_value_t = false)] pub ephemeral: bool, @@ -122,6 +71,71 @@ pub struct Cli { pub prompt: Option, } +impl std::ops::Deref for Cli { + type Target = SharedCliOptions; + + fn deref(&self) -> &Self::Target { + &self.shared.0 + } +} + +impl std::ops::DerefMut for Cli { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.shared.0 + } +} + +#[derive(Debug, Default)] +pub struct ExecSharedCliOptions(SharedCliOptions); + +impl ExecSharedCliOptions { + pub fn into_inner(self) -> SharedCliOptions { + self.0 + } +} + +impl std::ops::Deref for ExecSharedCliOptions { + type Target = SharedCliOptions; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for ExecSharedCliOptions { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Args for ExecSharedCliOptions { + fn augment_args(cmd: clap::Command) -> clap::Command { + mark_exec_global_args(SharedCliOptions::augment_args(cmd)) + } + + fn augment_args_for_update(cmd: clap::Command) -> clap::Command { + mark_exec_global_args(SharedCliOptions::augment_args_for_update(cmd)) + } +} + +impl FromArgMatches for ExecSharedCliOptions { + fn from_arg_matches(matches: &clap::ArgMatches) -> Result { + SharedCliOptions::from_arg_matches(matches).map(Self) + } + + fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> { + self.0.update_from_arg_matches(matches) + } +} + +fn mark_exec_global_args(cmd: clap::Command) -> clap::Command { + cmd.mut_arg("model", |arg| arg.global(true)) + .mut_arg("full_auto", |arg| arg.global(true)) + .mut_arg("dangerously_bypass_approvals_and_sandbox", |arg| { + arg.global(true) + }) +} + #[derive(Debug, clap::Subcommand)] pub enum Command { /// Resume a previous session by id or pick the most recent with --last. diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 6093e6e19066..781e423fde45 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -86,6 +86,7 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; +use codex_utils_cli::SharedCliOptions; use codex_utils_oss::ensure_oss_provider_ready; use codex_utils_oss::get_default_model_for_oss_provider; use event_processor_with_human_output::EventProcessorWithHumanOutput; @@ -219,27 +220,31 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let Cli { command, - images, - model: model_cli_arg, - oss, - oss_provider, - config_profile, - full_auto, - dangerously_bypass_approvals_and_sandbox, - cwd, + shared, skip_git_repo_check, - add_dir, ephemeral, ignore_user_config, ignore_rules, color, last_message_file, json: json_mode, - sandbox_mode: sandbox_mode_cli_arg, prompt, output_schema: output_schema_path, config_overrides, } = cli; + let shared = shared.into_inner(); + let SharedCliOptions { + images, + model: model_cli_arg, + oss, + oss_provider, + config_profile, + sandbox_mode: sandbox_mode_cli_arg, + full_auto, + dangerously_bypass_approvals_and_sandbox, + cwd, + add_dir, + } = shared; let (_stdout_with_ansi, stderr_with_ansi) = match color { cli::Color::Always => (true, true), diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index d1cbabf37fad..3d47442c905d 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -1,8 +1,9 @@ +use clap::Args; +use clap::FromArgMatches; use clap::Parser; -use clap::ValueHint; use codex_utils_cli::ApprovalModeCliArg; use codex_utils_cli::CliConfigOverrides; -use std::path::PathBuf; +use codex_utils_cli::SharedCliOptions; #[derive(Parser, Debug)] #[command(version)] @@ -11,10 +12,6 @@ pub struct Cli { #[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)] pub prompt: Option, - /// Optional image(s) to attach to the initial prompt. - #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] - pub images: Vec, - // Internal controls set by the top-level `codex resume` subcommand. // These are not exposed as user flags on the base `codex` command. #[clap(skip)] @@ -53,60 +50,17 @@ pub struct Cli { #[clap(skip)] pub fork_show_all: bool, - /// Model the agent should use. - #[arg(long, short = 'm')] - pub model: Option, - - /// Convenience flag to select the local open source model provider. Equivalent to -c - /// model_provider=oss; verifies a local LM Studio or Ollama server is running. - #[arg(long = "oss", default_value_t = false)] - pub oss: bool, - - /// Specify which local provider to use (lmstudio or ollama). - /// If not specified with --oss, will use config default or show selection. - #[arg(long = "local-provider")] - pub oss_provider: Option, - - /// Configuration profile from config.toml to specify default options. - #[arg(long = "profile", short = 'p')] - pub config_profile: Option, - - /// Select the sandbox policy to use when executing model-generated shell - /// commands. - #[arg(long = "sandbox", short = 's')] - pub sandbox_mode: Option, + #[clap(flatten)] + pub shared: TuiSharedCliOptions, /// Configure when the model requires human approval before executing a command. #[arg(long = "ask-for-approval", short = 'a')] pub approval_policy: Option, - /// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write). - #[arg(long = "full-auto", default_value_t = false)] - pub full_auto: bool, - - /// Skip all confirmation prompts and execute commands without sandboxing. - /// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed. - #[arg( - long = "dangerously-bypass-approvals-and-sandbox", - alias = "yolo", - default_value_t = false, - conflicts_with_all = ["approval_policy", "full_auto"] - )] - pub dangerously_bypass_approvals_and_sandbox: bool, - - /// Tell the agent to use the specified directory as its working root. - /// In remote mode, the path is forwarded to the server and resolved there. - #[clap(long = "cd", short = 'C', value_name = "DIR")] - pub cwd: Option, - /// Enable live web search. When enabled, the native Responses `web_search` tool is available to the model (no per‑call approval). #[arg(long = "search", default_value_t = false)] pub web_search: bool, - /// Additional directories that should be writable alongside the primary workspace. - #[arg(long = "add-dir", value_name = "DIR", value_hint = ValueHint::DirPath)] - pub add_dir: Vec, - /// Disable alternate screen mode /// /// Runs the TUI in inline mode, preserving terminal scrollback history. This is useful @@ -118,3 +72,66 @@ pub struct Cli { #[clap(skip)] pub config_overrides: CliConfigOverrides, } + +impl std::ops::Deref for Cli { + type Target = SharedCliOptions; + + fn deref(&self) -> &Self::Target { + &self.shared.0 + } +} + +impl std::ops::DerefMut for Cli { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.shared.0 + } +} + +#[derive(Debug, Default)] +pub struct TuiSharedCliOptions(SharedCliOptions); + +impl TuiSharedCliOptions { + pub fn into_inner(self) -> SharedCliOptions { + self.0 + } +} + +impl std::ops::Deref for TuiSharedCliOptions { + type Target = SharedCliOptions; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for TuiSharedCliOptions { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Args for TuiSharedCliOptions { + fn augment_args(cmd: clap::Command) -> clap::Command { + mark_tui_args(SharedCliOptions::augment_args(cmd)) + } + + fn augment_args_for_update(cmd: clap::Command) -> clap::Command { + mark_tui_args(SharedCliOptions::augment_args_for_update(cmd)) + } +} + +impl FromArgMatches for TuiSharedCliOptions { + fn from_arg_matches(matches: &clap::ArgMatches) -> Result { + SharedCliOptions::from_arg_matches(matches).map(Self) + } + + fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> { + self.0.update_from_arg_matches(matches) + } +} + +fn mark_tui_args(cmd: clap::Command) -> clap::Command { + cmd.mut_arg("dangerously_bypass_approvals_and_sandbox", |arg| { + arg.conflicts_with("approval_policy") + }) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 84160e25f829..7c60b2e38a28 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1387,10 +1387,11 @@ async fn run_ratatui_app( let Cli { prompt, - images, + shared, no_alt_screen, .. } = cli; + let images = shared.into_inner().images; let use_alt_screen = determine_alt_screen_mode(no_alt_screen, config.tui_alternate_screen); tui.set_alt_screen_enabled(use_alt_screen); diff --git a/codex-rs/utils/cli/src/lib.rs b/codex-rs/utils/cli/src/lib.rs index ed00d683f966..35e4fd1daf4d 100644 --- a/codex-rs/utils/cli/src/lib.rs +++ b/codex-rs/utils/cli/src/lib.rs @@ -2,8 +2,10 @@ mod approval_mode_cli_arg; mod config_override; pub(crate) mod format_env_display; mod sandbox_mode_cli_arg; +mod shared_options; pub use approval_mode_cli_arg::ApprovalModeCliArg; pub use config_override::CliConfigOverrides; pub use format_env_display::format_env_display; pub use sandbox_mode_cli_arg::SandboxModeCliArg; +pub use shared_options::SharedCliOptions; diff --git a/codex-rs/utils/cli/src/shared_options.rs b/codex-rs/utils/cli/src/shared_options.rs new file mode 100644 index 000000000000..b9e47c8d8411 --- /dev/null +++ b/codex-rs/utils/cli/src/shared_options.rs @@ -0,0 +1,174 @@ +//! Shared command-line flags used by both interactive and non-interactive Codex entry points. + +use crate::SandboxModeCliArg; +use clap::Args; +use std::path::PathBuf; + +#[derive(Args, Debug, Default)] +pub struct SharedCliOptions { + /// Optional image(s) to attach to the initial prompt. + #[arg( + long = "image", + short = 'i', + value_name = "FILE", + value_delimiter = ',', + num_args = 1.. + )] + pub images: Vec, + + /// Model the agent should use. + #[arg(long, short = 'm')] + pub model: Option, + + /// Use open-source provider. + #[arg(long = "oss", default_value_t = false)] + pub oss: bool, + + /// Specify which local provider to use (lmstudio or ollama). + /// If not specified with --oss, will use config default or show selection. + #[arg(long = "local-provider")] + pub oss_provider: Option, + + /// Configuration profile from config.toml to specify default options. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + + /// Select the sandbox policy to use when executing model-generated shell + /// commands. + #[arg(long = "sandbox", short = 's')] + pub sandbox_mode: Option, + + /// Convenience alias for low-friction sandboxed automatic execution. + #[arg(long = "full-auto", default_value_t = false)] + pub full_auto: bool, + + /// Skip all confirmation prompts and execute commands without sandboxing. + /// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed. + #[arg( + long = "dangerously-bypass-approvals-and-sandbox", + alias = "yolo", + default_value_t = false, + conflicts_with = "full_auto" + )] + pub dangerously_bypass_approvals_and_sandbox: bool, + + /// Tell the agent to use the specified directory as its working root. + #[clap(long = "cd", short = 'C', value_name = "DIR")] + pub cwd: Option, + + /// Additional directories that should be writable alongside the primary workspace. + #[arg(long = "add-dir", value_name = "DIR", value_hint = clap::ValueHint::DirPath)] + pub add_dir: Vec, +} + +impl SharedCliOptions { + pub fn inherit_exec_root_options(&mut self, root: &Self) { + let self_selected_sandbox_mode = self.sandbox_mode.is_some() + || self.full_auto + || self.dangerously_bypass_approvals_and_sandbox; + let Self { + images, + model, + oss, + oss_provider, + config_profile, + sandbox_mode, + full_auto, + dangerously_bypass_approvals_and_sandbox, + cwd, + add_dir, + } = self; + let Self { + images: root_images, + model: root_model, + oss: root_oss, + oss_provider: root_oss_provider, + config_profile: root_config_profile, + sandbox_mode: root_sandbox_mode, + full_auto: root_full_auto, + dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox, + cwd: root_cwd, + add_dir: root_add_dir, + } = root; + + if model.is_none() { + model.clone_from(root_model); + } + if *root_oss { + *oss = true; + } + if oss_provider.is_none() { + oss_provider.clone_from(root_oss_provider); + } + if config_profile.is_none() { + config_profile.clone_from(root_config_profile); + } + if sandbox_mode.is_none() { + *sandbox_mode = *root_sandbox_mode; + } + if !self_selected_sandbox_mode { + *full_auto = *root_full_auto; + *dangerously_bypass_approvals_and_sandbox = + *root_dangerously_bypass_approvals_and_sandbox; + } + if cwd.is_none() { + cwd.clone_from(root_cwd); + } + if !root_images.is_empty() { + let mut merged_images = root_images.clone(); + merged_images.append(images); + *images = merged_images; + } + if !root_add_dir.is_empty() { + let mut merged_add_dir = root_add_dir.clone(); + merged_add_dir.append(add_dir); + *add_dir = merged_add_dir; + } + } + + pub fn apply_subcommand_overrides(&mut self, subcommand: Self) { + let subcommand_selected_sandbox_mode = subcommand.sandbox_mode.is_some() + || subcommand.full_auto + || subcommand.dangerously_bypass_approvals_and_sandbox; + let Self { + images, + model, + oss, + oss_provider, + config_profile, + sandbox_mode, + full_auto, + dangerously_bypass_approvals_and_sandbox, + cwd, + add_dir, + } = subcommand; + + if let Some(model) = model { + self.model = Some(model); + } + if oss { + self.oss = true; + } + if let Some(oss_provider) = oss_provider { + self.oss_provider = Some(oss_provider); + } + if let Some(config_profile) = config_profile { + self.config_profile = Some(config_profile); + } + if subcommand_selected_sandbox_mode { + self.sandbox_mode = sandbox_mode; + self.full_auto = full_auto; + self.dangerously_bypass_approvals_and_sandbox = + dangerously_bypass_approvals_and_sandbox; + } + if let Some(cwd) = cwd { + self.cwd = Some(cwd); + } + if !images.is_empty() { + self.images = images; + } + if !add_dir.is_empty() { + self.add_dir.extend(add_dir); + } + } +}