use anyhow::Context;use codex_features::Feature;use codex_protocol::models::PermissionProfile;use codex_protocol::permissions::NetworkSandboxPolicy;use codex_protocol::protocol::AskForApproval;use codex_protocol::protocol::EventMsg;use codex_protocol::protocol::ExecCommandEndEvent;use codex_protocol::protocol::ExecCommandSource;use codex_protocol::protocol::ExecOutputStream;use codex_protocol::protocol::Op;use codex_protocol::protocol::TurnAbortReason;use codex_protocol::user_input::UserInput;use core_test_support::PathBufExt;use core_test_support::assert_regex_match;use core_test_support::responses;use core_test_support::responses::ev_assistant_message;use core_test_support::responses::ev_completed;use core_test_support::responses::ev_function_call;use core_test_support::responses::ev_response_created;use core_test_support::responses::mount_sse_once;use core_test_support::responses::sse;use core_test_support::responses::start_mock_server;use core_test_support::skip_if_no_network;use core_test_support::submit_thread_settings;use core_test_support::test_codex::local_selections;use core_test_support::test_codex::test_codex;use core_test_support::test_codex::turn_permission_fields;use core_test_support::wait_for_event;use core_test_support::wait_for_event_match;use core_test_support::wait_for_event_with_timeout;use pretty_assertions::assert_eq;use regex_lite::escape;use std::path::PathBuf;use tempfile::TempDir;use tokio::time::Duration;use tokio::time::timeout;#[tokio::test]async fn user_shell_cmd_ls_and_cat_in_temp_dir() { // Create a temporary working directory with a known file. let cwd = TempDir::new().unwrap(); let file_name = "hello.txt"; let file_path: PathBuf = cwd.path().join(file_name); let contents = "hello from bang test\n"; tokio::fs::write(&file_path, contents) .await .expect("write temp file"); // Pin cwd to the temp dir so ls/cat operate there. let server = start_mock_server().await; let cwd_path = cwd.path().to_path_buf(); let mut builder = test_codex().with_config(move |config| { config.cwd = cwd_path.abs(); }); let codex = builder .build(&server) .await .expect("create new conversation") .codex; // 1) shell command should list the file let list_cmd = "ls".to_string(); codex .submit(Op::RunUserShellCommand { command: list_cmd }) .await .unwrap(); let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandEnd(_))).await; let EventMsg::ExecCommandEnd(ExecCommandEndEvent { stdout, exit_code, .. }) = msg else { unreachable!() }; assert_eq!(exit_code, 0); assert!( stdout.contains(file_name), "ls output should include {file_name}, got: {stdout:?}" ); // 2) shell command should print the file contents verbatim let cat_cmd = format!("cat {file_name}"); codex .submit(Op::RunUserShellCommand { command: cat_cmd }) .await .unwrap(); let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandEnd(_))).await; let EventMsg::ExecCommandEnd(ExecCommandEndEvent { mut stdout, exit_code, .. }) = msg else { unreachable!() }; assert_eq!(exit_code, 0); if cfg!(windows) { // Windows shells emit CRLF line endings; normalize so the assertion remains portable. stdout = stdout.replace("\r\n", "\n"); } assert_eq!(stdout, contents);}#[tokio::test]async fn user_shell_command_without_local_environment_emits_error() -> anyhow::Result<()> { let server = start_mock_server().await; let mut builder = test_codex(); let test = builder.build(&server).await?; submit_thread_settings( &test.codex, codex_protocol::protocol::ThreadSettingsOverrides { environments: Some(codex_protocol::protocol::TurnEnvironmentSelections::new( test.config.cwd.clone(), vec![], )), ..Default::default() }, ) .await?; test.codex .submit(Op::RunUserShellCommand { command: "echo shell".to_string(), }) .await?; let EventMsg::Error(error) = wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await else { unreachable!() }; assert_eq!(error.message, "shell is unavailable in this session"); assert_eq!(error.codex_error_info, None); Ok(())}#[tokio::test]async fn user_shell_cmd_can_be_interrupted() { // Set up isolated config and conversation. let server = start_mock_server().await; let mut builder = test_codex(); let fixture = builder .build(&server) .await .expect("create new conversation"); let codex = &fixture.codex; // Start a long-running command and then interrupt it. let sleep_cmd = "sleep 5".to_string(); codex .submit(Op::RunUserShellCommand { command: sleep_cmd }) .await .unwrap(); // Wait until it has started (ExecCommandBegin), then interrupt. let _begin = wait_for_event_match(codex, |ev| match ev { EventMsg::ExecCommandBegin(event) if event.source == ExecCommandSource::UserShell => { Some(event.clone()) } _ => None, }) .await; codex.submit(Op::Interrupt).await.unwrap(); // Expect a TurnAborted(Interrupted) notification. let msg = wait_for_event_with_timeout( codex, |ev| matches!(ev, EventMsg::TurnAborted(_)), Duration::from_secs(60), ) .await; let EventMsg::TurnAborted(ev) = msg else { unreachable!() }; assert_eq!(ev.reason, TurnAbortReason::Interrupted);}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5.4"); let fixture = builder.build(&server).await?; let call_id = "active-turn-shell-call"; let args = if cfg!(windows) { serde_json::json!({ "command": "Start-Sleep -Seconds 2; Write-Output model-shell", "timeout_ms": 10_000, }) } else { serde_json::json!({ "command": "sleep 2; echo model-shell", "timeout_ms": 10_000, }) }; let first = sse(vec![ ev_response_created("resp-1"), ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]); let second = sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]); let mock = responses::mount_sse_sequence(&server, vec![first, second]).await; let cwd = fixture.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); fixture .codex .submit(Op::UserInput { items: vec![UserInput::Text { text: "run model shell command".to_string(), text_elements: Vec::new(), }], final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { environments: Some(local_selections(cwd)), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: fixture.session_configured.model.clone(), reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() }, }) .await?; let _ = wait_for_event_match(&fixture.codex, |ev| match ev { EventMsg::ExecCommandBegin(event) if event.source == ExecCommandSource::Agent => { Some(event.clone()) } _ => None, }) .await; #[cfg(windows)] let user_shell_command = "Write-Output user-shell".to_string(); #[cfg(not(windows))] let user_shell_command = "printf user-shell".to_string(); fixture .codex .submit(Op::RunUserShellCommand { command: user_shell_command, }) .await?; let mut saw_replaced_abort = false; let mut saw_user_shell_end = false; let mut saw_turn_complete = false; for _ in 0..200 { let event = timeout(Duration::from_secs(20), fixture.codex.next_event()) .await .context("timed out waiting for event")? .context("event stream ended unexpectedly")?; match event.msg { EventMsg::TurnAborted(ev) if ev.reason == TurnAbortReason::Replaced => { saw_replaced_abort = true; } EventMsg::ExecCommandEnd(ev) if ev.source == ExecCommandSource::UserShell => { saw_user_shell_end = true; } EventMsg::TurnComplete(_) => { saw_turn_complete = true; break; } _ => {} } } assert!(saw_turn_complete, "expected turn to complete"); assert!( saw_user_shell_end, "expected user shell command to finish while turn was active" ); assert!( !saw_replaced_abort, "user shell command should not replace the active turn" ); assert_eq!( mock.requests().len(), 2, "active turn should continue and issue the follow-up model request" ); Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn user_shell_command_history_is_persisted_and_shared_with_model() -> anyhow::Result<()> { let server = responses::start_mock_server().await; // Disable it to ease command matching. let mut builder = core_test_support::test_codex::test_codex().with_config(move |config| { config .features .disable(Feature::ShellSnapshot) .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; #[cfg(windows)] let command = r#"$val = $env:CODEX_SANDBOX; if ([string]::IsNullOrEmpty($val)) { $val = 'not-set' } ; [System.Console]::Write($val)"#.to_string(); #[cfg(not(windows))] let command = r#"sh -c "printf '%s' \"${CODEX_SANDBOX:-not-set}\"""#.to_string(); test.codex .submit(Op::RunUserShellCommand { command: command.clone(), }) .await?; let begin_event = wait_for_event_match(&test.codex, |ev| match ev { EventMsg::ExecCommandBegin(event) => Some(event.clone()), _ => None, }) .await; assert_eq!(begin_event.source, ExecCommandSource::UserShell); let matches_last_arg = begin_event.command.last() == Some(&command); let matches_split = shlex::split(&command).is_some_and(|split| split == begin_event.command); assert!( matches_last_arg || matches_split, "user command begin event should include the original command; got: {:?}", begin_event.command ); let delta_event = wait_for_event_match(&test.codex, |ev| match ev { EventMsg::ExecCommandOutputDelta(event) => Some(event.clone()), _ => None, }) .await; assert_eq!(delta_event.stream, ExecOutputStream::Stdout); let chunk_text = String::from_utf8(delta_event.chunk.clone()).expect("user command chunk is valid utf-8"); assert_eq!(chunk_text.trim(), "not-set"); let end_event = wait_for_event_match(&test.codex, |ev| match ev { EventMsg::ExecCommandEnd(event) => Some(event.clone()), _ => None, }) .await; assert_eq!(end_event.exit_code, 0); assert_eq!(end_event.stdout.trim(), "not-set"); let _ = wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let responses = vec![responses::sse(vec![ responses::ev_response_created("resp-1"), responses::ev_assistant_message("msg-1", "done"), responses::ev_completed("resp-1"), ])]; let mock = responses::mount_sse_sequence(&server, responses).await; test.submit_turn("follow-up after shell command").await?; let request = mock.single_request(); let command_message = request .message_input_texts("user") .into_iter() .find(|text| text.contains("<user_shell_command>")) .expect("command message recorded in request"); let command_message = command_message.replace("\r\n", "\n"); let escaped_command = escape(&command); let expected_pattern = format!( r"(?m)\A<user_shell_command>\n<command>\n{escaped_command}\n</command>\n<result>\nExit code: 0\nDuration: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\nnot-set\n</result>\n</user_shell_command>\z" ); assert_regex_match(&expected_pattern, &command_message); Ok(())}#[tokio::test]async fn user_shell_command_does_not_set_network_sandbox_env_var() -> anyhow::Result<()> { let server = responses::start_mock_server().await; let mut builder = core_test_support::test_codex::test_codex().with_config(|config| { let file_system_sandbox_policy = config.permissions.file_system_sandbox_policy(); config .permissions .set_permission_profile(PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, NetworkSandboxPolicy::Restricted, )) .expect("set permission profile"); }); let test = builder.build(&server).await?; #[cfg(windows)] let command = r#"$val = $env:CODEX_SANDBOX_NETWORK_DISABLED; if ([string]::IsNullOrEmpty($val)) { $val = 'not-set' } ; [System.Console]::Write($val)"#.to_string(); #[cfg(not(windows))] let command = r#"sh -c "printf '%s' \"${CODEX_SANDBOX_NETWORK_DISABLED:-not-set}\"""#.to_string(); test.codex .submit(Op::RunUserShellCommand { command }) .await?; let ExecCommandEndEvent { exit_code, stdout, stderr, .. } = wait_for_event_match(&test.codex, |ev| match ev { EventMsg::ExecCommandEnd(event) => Some(event.clone()), _ => None, }) .await; assert_eq!( exit_code, 0, "shell command should execute successfully. stdout=`{stdout}`, stderr=`{stderr}`", ); assert_eq!(stdout.trim(), "not-set"); Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]#[cfg(not(target_os = "windows"))] // TODO: unignore on windowsasync fn user_shell_command_output_is_truncated_in_history() -> anyhow::Result<()> { let server = responses::start_mock_server().await; let builder = core_test_support::test_codex::test_codex(); let test = builder .with_config(|config| { config.tool_output_token_limit = Some(100); }) .build(&server) .await?; #[cfg(windows)] let command = r#"for ($i=1; $i -le 400; $i++) { Write-Output $i }"#.to_string(); #[cfg(not(windows))] let command = "seq 1 400".to_string(); test.codex .submit(Op::RunUserShellCommand { command: command.clone(), }) .await?; let end_event = wait_for_event_match(&test.codex, |ev| match ev { EventMsg::ExecCommandEnd(event) => Some(event.clone()), _ => None, }) .await; assert_eq!(end_event.exit_code, 0); let _ = wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let responses = vec![responses::sse(vec![ responses::ev_response_created("resp-1"), responses::ev_assistant_message("msg-1", "done"), responses::ev_completed("resp-1"), ])]; let mock = responses::mount_sse_sequence(&server, responses).await; test.submit_turn("follow-up after shell command").await?; let request = mock.single_request(); let command_message = request .message_input_texts("user") .into_iter() .find(|text| text.contains("<user_shell_command>")) .expect("command message recorded in request"); let command_message = command_message.replace("\r\n", "\n"); let head = (1..=69).map(|i| format!("{i}\n")).collect::<String>(); let tail = (352..=400).map(|i| format!("{i}\n")).collect::<String>(); let truncated_body = format!( "Warning: truncated output (original token count: 373)\nTotal output lines: 400\n\n{head}70…273 tokens truncated…351\n{tail}" ); let escaped_command = escape(&command); let escaped_truncated_body = escape(&truncated_body); let expected_pattern = format!( r"(?m)\A<user_shell_command>\n<command>\n{escaped_command}\n</command>\n<result>\nExit code: 0\nDuration: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n{escaped_truncated_body}\n</result>\n</user_shell_command>\z" ); assert_regex_match(&expected_pattern, &command_message); Ok(())}#[tokio::test(flavor = "multi_thread", worker_threads = 2)]async fn user_shell_command_is_truncated_only_once() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let mut builder = test_codex().with_model("gpt-5.4").with_config(|config| { config.tool_output_token_limit = Some(100); }); let fixture = builder.build(&server).await?; let call_id = "user-shell-double-truncation"; let args = if cfg!(windows) { serde_json::json!({ "command": "for ($i=1; $i -le 2000; $i++) { Write-Output $i }", "timeout_ms": 5_000, }) } else { serde_json::json!({ "command": "seq 1 2000", "timeout_ms": 5_000, }) }; mount_sse_once( &server, sse(vec![ ev_response_created("resp-1"), ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) .await; let mock2 = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), ) .await; fixture .submit_turn_with_permission_profile( "trigger big shell_command output", PermissionProfile::Disabled, ) .await?; let output = mock2 .single_request() .function_call_output_text(call_id) .context("function_call_output present for shell_command call")?; let truncation_headers = output.matches("Total output lines:").count(); assert_eq!( truncation_headers, 1, "shell_command output should carry only one truncation header: {output}" ); Ok(())}