diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2cd57f06e7..430c61826b 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -2218,6 +2218,52 @@ mod tests { assert_eq!(interactive.resume_session_id, None); } + #[test] + fn resume_merges_not_so_yolo_flag() { + let interactive = finalize_resume_from_args(["codex", "resume", "--not-so-yolo"].as_ref()); + + assert!(interactive.auto_review_cli_mode); + assert!(interactive.resume_picker); + assert!(!interactive.resume_last); + assert_eq!(interactive.resume_session_id, None); + } + + #[test] + fn exec_inherits_root_not_so_yolo_flag() { + let cli = MultitoolCli::try_parse_from(["codex", "--not-so-yolo", "exec", "hello"]) + .expect("parse"); + let MultitoolCli { + interactive, + config_overrides: _, + feature_toggles: _, + remote: _, + subcommand, + } = cli; + let Some(Subcommand::Exec(mut exec_cli)) = subcommand else { + panic!("expected exec subcommand"); + }; + + exec_cli + .shared + .inherit_exec_root_options(&interactive.shared); + + assert!(exec_cli.auto_review_cli_mode); + assert!(!exec_cli.dangerously_bypass_approvals_and_sandbox); + } + + #[test] + fn not_so_yolo_conflicts_with_approval_policy() { + let err = MultitoolCli::try_parse_from([ + "codex", + "--not-so-yolo", + "--ask-for-approval", + "on-request", + ]) + .expect_err("conflicting permission flags should be rejected"); + + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + #[test] fn fork_picker_logic_none_and_not_last() { let interactive = finalize_fork_from_args(["codex", "fork"].as_ref()); diff --git a/codex-rs/exec/src/cli_tests.rs b/codex-rs/exec/src/cli_tests.rs index 45f2aa330d..c0e7461935 100644 --- a/codex-rs/exec/src/cli_tests.rs +++ b/codex-rs/exec/src/cli_tests.rs @@ -35,6 +35,25 @@ fn resume_parses_prompt_after_global_flags() { assert_eq!(effective_prompt.as_deref(), Some(PROMPT)); } +#[test] +fn resume_parses_prompt_after_not_so_yolo_global_flag() { + const PROMPT: &str = "echo resume-with-not-so-yolo-after-subcommand"; + let cli = Cli::parse_from(["codex-exec", "resume", "--last", "--not-so-yolo", PROMPT]); + + assert!(cli.auto_review_cli_mode); + let Some(Command::Resume(args)) = cli.command else { + panic!("expected resume command"); + }; + let effective_prompt = args.prompt.clone().or_else(|| { + if args.last { + args.session_id.clone() + } else { + None + } + }); + assert_eq!(effective_prompt.as_deref(), Some(PROMPT)); +} + #[test] fn resume_accepts_output_last_message_flag_after_subcommand() { const PROMPT: &str = "echo resume-with-output-file"; diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index c6fa0f9fde..081a6d2ca3 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -4,6 +4,7 @@ mod apply_patch; mod auth_env; mod ephemeral; mod mcp_required_exit; +mod not_so_yolo; mod originator; mod output_schema; mod prompt_stdin; diff --git a/codex-rs/exec/tests/suite/not_so_yolo.rs b/codex-rs/exec/tests/suite/not_so_yolo.rs new file mode 100644 index 0000000000..d6a59c8cec --- /dev/null +++ b/codex-rs/exec/tests/suite/not_so_yolo.rs @@ -0,0 +1,46 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Context; +use codex_utils_cargo_bin::find_resource; +use core_test_support::test_codex_exec::test_codex_exec; + +fn exec_fixture() -> anyhow::Result { + Ok(find_resource!("tests/fixtures/cli_responses_fixture.sse")?) +} + +#[test] +fn not_so_yolo_uses_on_request_approvals_with_workspace_write() -> anyhow::Result<()> { + let test = test_codex_exec(); + let fixture = exec_fixture()?; + let repo_root = codex_utils_cargo_bin::repo_root()?; + + let output = test + .cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .arg("--skip-git-repo-check") + .arg("--not-so-yolo") + .arg("-C") + .arg(&repo_root) + .arg("hello") + .output() + .context("not-so-yolo run should succeed")?; + + assert!(output.status.success(), "run failed: {output:?}"); + + let stderr = String::from_utf8(output.stderr)?; + assert!( + stderr.contains("approval: on-request"), + "stderr missing on-request approval mode: {stderr}" + ); + let expected_sandbox = if cfg!(target_os = "windows") { + "sandbox: read-only" + } else { + "sandbox: workspace-write" + }; + assert!( + stderr.contains(expected_sandbox), + "stderr missing expected sandbox summary `{expected_sandbox}`: {stderr}" + ); + + Ok(()) +} diff --git a/codex-rs/utils/cli/src/shared_options.rs b/codex-rs/utils/cli/src/shared_options.rs index f8535637ef..d1521095d9 100644 --- a/codex-rs/utils/cli/src/shared_options.rs +++ b/codex-rs/utils/cli/src/shared_options.rs @@ -175,3 +175,66 @@ impl SharedCliOptions { } } } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use pretty_assertions::assert_eq; + + #[derive(Debug, Parser)] + struct TestCli { + #[clap(flatten)] + shared: SharedCliOptions, + } + + #[test] + fn parses_not_so_yolo() { + let cli = TestCli::parse_from(["test", "--not-so-yolo"]); + + assert!(cli.shared.auto_review_cli_mode); + assert!(!cli.shared.dangerously_bypass_approvals_and_sandbox); + } + + #[test] + fn yolo_and_not_so_yolo_conflict() { + let err = TestCli::try_parse_from(["test", "--yolo", "--not-so-yolo"]) + .expect_err("permission modes should conflict"); + + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn not_so_yolo_inherits_to_exec_subcommand_options() { + let root = SharedCliOptions { + auto_review_cli_mode: true, + ..Default::default() + }; + let mut exec = SharedCliOptions::default(); + + exec.inherit_exec_root_options(&root); + + assert!(exec.auto_review_cli_mode); + assert!(!exec.dangerously_bypass_approvals_and_sandbox); + } + + #[test] + fn subcommand_permission_mode_blocks_root_not_so_yolo() { + let root = SharedCliOptions { + auto_review_cli_mode: true, + ..Default::default() + }; + let mut exec = SharedCliOptions { + sandbox_mode: Some(SandboxModeCliArg::ReadOnly), + ..Default::default() + }; + + exec.inherit_exec_root_options(&root); + + assert!(!exec.auto_review_cli_mode); + assert!(matches!( + exec.sandbox_mode, + Some(SandboxModeCliArg::ReadOnly) + )); + } +}