diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d6ff1d2061..01d167cd0e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4325,7 +4325,6 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-output-truncation", - "codex-utils-pty", "codex-utils-string", "jsonptr", "pretty_assertions", diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 538274ee54..7b545e94f3 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -37,12 +37,12 @@ pub use mock_model_server::create_mock_responses_server_sequence_unchecked; pub use models_cache::write_models_cache; pub use models_cache::write_models_cache_with_models; pub use responses::create_apply_patch_sse_response; -pub use responses::create_escalated_shell_command_sse_response; +pub use responses::create_command_execution_sse_response; +pub use responses::create_escalated_command_execution_sse_response; pub use responses::create_exec_command_sse_response; pub use responses::create_final_assistant_message_sse_response; pub use responses::create_request_permissions_sse_response; pub use responses::create_request_user_input_sse_response; -pub use responses::create_shell_command_sse_response; pub use rollout::create_fake_paginated_rollout; pub use rollout::create_fake_parented_rollout_with_source; pub use rollout::create_fake_rollout; diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 2d62aa1968..2bcb5a6d52 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -21,7 +21,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { description: Some(preset.description.clone()), default_reasoning_level: Some(preset.default_reasoning_effort.clone()), supported_reasoning_levels: preset.supported_reasoning_efforts.clone(), - shell_type: ConfigShellToolType::ShellCommand, + shell_type: ConfigShellToolType::UnifiedExec, visibility: if preset.show_in_picker { ModelVisibility::List } else { diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index a862259b06..3e726309e1 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -2,27 +2,29 @@ use core_test_support::responses; use serde_json::json; use std::path::Path; -pub fn create_shell_command_sse_response( +pub fn create_command_execution_sse_response( command: Vec, workdir: Option<&Path>, timeout_ms: Option, call_id: &str, ) -> anyhow::Result { - // The `arguments` for the `shell_command` tool is a serialized JSON object. let command_str = shlex::try_join(command.iter().map(String::as_str))?; - let tool_call_arguments = serde_json::to_string(&json!({ - "command": command_str, + let mut arguments = json!({ + "cmd": command_str, "workdir": workdir.map(|w| w.to_string_lossy()), - "timeout_ms": timeout_ms - }))?; + }); + if let Some(timeout_ms) = timeout_ms { + arguments["yield_time_ms"] = json!(timeout_ms); + } + let tool_call_arguments = serde_json::to_string(&arguments)?; Ok(responses::sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call(call_id, "shell_command", &tool_call_arguments), + responses::ev_function_call(call_id, "exec_command", &tool_call_arguments), responses::ev_completed("resp-1"), ])) } -pub fn create_escalated_shell_command_sse_response( +pub fn create_escalated_command_execution_sse_response( command: Vec, workdir: Option<&Path>, timeout_ms: Option, @@ -30,15 +32,15 @@ pub fn create_escalated_shell_command_sse_response( ) -> anyhow::Result { let command_str = shlex::try_join(command.iter().map(String::as_str))?; let tool_call_arguments = serde_json::to_string(&json!({ - "command": command_str, + "cmd": command_str, "workdir": workdir.map(|w| w.to_string_lossy()), - "timeout_ms": timeout_ms, + "yield_time_ms": timeout_ms, "sandbox_permissions": "require_escalated", "justification": "Test approval request." }))?; Ok(responses::sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call(call_id, "shell_command", &tool_call_arguments), + responses::ev_function_call(call_id, "exec_command", &tool_call_arguments), responses::ev_completed("resp-1"), ])) } @@ -57,7 +59,7 @@ pub fn create_apply_patch_sse_response( ) -> anyhow::Result { Ok(responses::sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_apply_patch_shell_command_call_via_heredoc(call_id, patch_content), + responses::ev_apply_patch_exec_command_call_via_heredoc(call_id, patch_content), responses::ev_completed("resp-1"), ])) } diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index d968eec4c7..d29113c188 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -4,7 +4,6 @@ use app_test_support::DEFAULT_CLIENT_NAME; use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; -use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; @@ -256,12 +255,6 @@ pub(crate) fn assert_basic_thread_initialized_event( const METRICS_PLUGIN_ID: &str = "sample@openai-curated"; const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; -#[derive(Clone, Copy)] -enum PluginMetricsRuntime { - Classic, - Unified { remote: bool, background: bool }, -} - fn write_curated_metrics_plugin(codex_home: &Path) -> Result { let plugin_id = PluginId::parse(METRICS_PLUGIN_ID)?; let plugin_root = PluginStore::new(codex_home.to_path_buf()).plugin_root( @@ -314,7 +307,7 @@ printf '%s' '{"version":1,"measurements":[{"name":"findings","value":3,"dimensio Ok(script_path.into_path_buf()) } -async fn assert_plugin_measurement_analytics(runtime: PluginMetricsRuntime) -> Result<()> { +async fn assert_plugin_measurement_analytics(remote: bool, background: bool) -> Result<()> { skip_if_no_network!(Ok(())); skip_if_remote!( Ok(()), @@ -324,11 +317,6 @@ async fn assert_plugin_measurement_analytics(runtime: PluginMetricsRuntime) -> R let codex_home = TempDir::new()?; let script_path = write_curated_metrics_plugin(codex_home.path())?.canonicalize()?; - let (remote, background) = match runtime { - PluginMetricsRuntime::Classic => (false, false), - PluginMetricsRuntime::Unified { remote, background } => (remote, background), - }; - let unified_exec = matches!(runtime, PluginMetricsRuntime::Unified { .. }); let mut command = vec![ "/bin/sh".to_string(), script_path.to_string_lossy().into_owned(), @@ -337,22 +325,15 @@ async fn assert_plugin_measurement_analytics(runtime: PluginMetricsRuntime) -> R command.push("1.0".to_string()); } let call_id = "curated-plugin-metrics"; - let command_response = match runtime { - PluginMetricsRuntime::Classic => { - create_shell_command_sse_response(command, /*workdir*/ None, Some(5_000), call_id)? - } - PluginMetricsRuntime::Unified { .. } => { - let arguments = serde_json::to_string(&json!({ - "cmd": shlex::try_join(command.iter().map(String::as_str))?, - "yield_time_ms": if background { 10 } else { 1_000 }, - }))?; - responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_function_call(call_id, "exec_command", &arguments), - responses::ev_completed("resp-1"), - ]) - } - }; + let arguments = serde_json::to_string(&json!({ + "cmd": shlex::try_join(command.iter().map(String::as_str))?, + "yield_time_ms": if background { 10 } else { 1_000 }, + }))?; + let command_response = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, "exec_command", &arguments), + responses::ev_completed("resp-1"), + ]); let final_response = create_final_assistant_message_sse_response("done")?; let server = create_mock_responses_server_sequence(vec![command_response, final_response]).await; @@ -372,7 +353,7 @@ async fn assert_plugin_measurement_analytics(runtime: PluginMetricsRuntime) -> R [features] plugins = true remote_plugin = false -unified_exec = {unified_exec} +unified_exec = true shell_zsh_fork = false unified_exec_zsh_fork = false @@ -577,49 +558,27 @@ enabled = true Ok(()) } -#[cfg_attr(windows, ignore = "plugin metrics fixture is Unix-only")] -#[tokio::test] -async fn classic_plugin_script_emits_measurement_analytics() -> Result<()> { - assert_plugin_measurement_analytics(PluginMetricsRuntime::Classic).await -} - #[cfg_attr(windows, ignore = "plugin metrics fixture is Unix-only")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_plugin_script_emits_measurement_analytics() -> Result<()> { - assert_plugin_measurement_analytics(PluginMetricsRuntime::Unified { - remote: false, - background: false, - }) - .await + assert_plugin_measurement_analytics(/*remote*/ false, /*background*/ false).await } #[cfg_attr(windows, ignore = "plugin metrics fixture is Unix-only")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_unified_plugin_script_emits_measurement_analytics() -> Result<()> { - assert_plugin_measurement_analytics(PluginMetricsRuntime::Unified { - remote: true, - background: false, - }) - .await + assert_plugin_measurement_analytics(/*remote*/ true, /*background*/ false).await } #[cfg_attr(windows, ignore = "plugin metrics fixture is Unix-only")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_background_plugin_script_emits_measurements_after_turn_completion() -> Result<()> { - assert_plugin_measurement_analytics(PluginMetricsRuntime::Unified { - remote: false, - background: true, - }) - .await + assert_plugin_measurement_analytics(/*remote*/ false, /*background*/ true).await } #[cfg_attr(windows, ignore = "plugin metrics fixture is Unix-only")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_unified_background_plugin_script_emits_measurements_after_turn_completion() -> Result<()> { - assert_plugin_measurement_analytics(PluginMetricsRuntime::Unified { - remote: true, - background: true, - }) - .await + assert_plugin_measurement_analytics(/*remote*/ true, /*background*/ true).await } diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 3361ca7955..9164a258a4 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -2,9 +2,9 @@ use anyhow::Context; use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; +use app_test_support::create_command_execution_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence_unchecked; -use app_test_support::create_shell_command_sse_response; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; @@ -2942,7 +2942,7 @@ async fn websocket_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Resul // calls the `background_agent` function; the shell command is requested by the delegated // background agent Responses turn that app-server starts after receiving that function call. let main_loop = main_loop_responses(vec![ - create_shell_command_sse_response( + create_command_execution_sse_response( realtime_tool_ok_command(), /*workdir*/ None, // Windows CI can spend several seconds starting the nested PowerShell command. This diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 070e441563..d391c8f36b 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -1,10 +1,10 @@ use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; +use app_test_support::create_command_execution_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::create_mock_responses_server_sequence; -use app_test_support::create_shell_command_sse_response; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; @@ -195,7 +195,7 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<() #[ignore = "TODO(owenlin0): flaky"] async fn review_start_exec_approval_item_id_matches_command_execution_item() -> Result<()> { let responses = vec![ - create_shell_command_sse_response( + create_command_execution_sse_response( vec![ "git".to_string(), "rev-parse".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_queue.rs b/codex-rs/app-server/tests/suite/v2/thread_queue.rs index 67291b0255..d094af9223 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_queue.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_queue.rs @@ -4,7 +4,7 @@ use anyhow::Context; use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; -use app_test_support::create_escalated_shell_command_sse_response; +use app_test_support::create_escalated_command_execution_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; @@ -888,7 +888,7 @@ fn blocked_turn_response() -> Result { "import time; time.sleep(10)".to_string(), ]; - create_escalated_shell_command_sse_response( + create_escalated_command_execution_sse_response( shell_command, /*workdir*/ None, /*timeout_ms*/ Some(10_000), diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 013d7595d5..7d2a6d495b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -3,6 +3,7 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; use app_test_support::create_apply_patch_sse_response; +use app_test_support::create_command_execution_sse_response; use app_test_support::create_fake_paginated_rollout; use app_test_support::create_fake_rollout; use app_test_support::create_fake_rollout_with_text_elements; @@ -10,7 +11,6 @@ use app_test_support::create_fake_rollout_with_token_usage; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::create_mock_responses_server_sequence_unchecked; -use app_test_support::create_shell_command_sse_response; use app_test_support::rollout_path; use app_test_support::test_absolute_path; use app_test_support::to_response; @@ -4507,7 +4507,7 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R let responses = vec![ create_final_assistant_message_sse_response("seeded")?, - create_shell_command_sse_response( + create_command_execution_sse_response( vec![ "python3".to_string(), "-c".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index 891201d644..a722791bde 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -1,7 +1,7 @@ use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; -use app_test_support::create_escalated_shell_command_sse_response; +use app_test_support::create_escalated_command_execution_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::format_with_current_shell_display; @@ -202,7 +202,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { std::fs::create_dir(&workspace)?; let responses = vec![ - create_escalated_shell_command_sse_response( + create_escalated_command_execution_sse_response( vec![ "python3".to_string(), "-c".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index 4e3225e7a4..02e2762292 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,10 +3,10 @@ use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; +use app_test_support::create_command_execution_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; -use app_test_support::create_shell_command_sse_response; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::RequestId; @@ -53,14 +53,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = - create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_command_execution_sse_response( shell_command.clone(), Some(&working_directory), Some(10_000), "call_sleep", - )?]) - .await; + )?, + ]) + .await; MockResponsesConfig::new(&server.uri()) .with_sandbox_mode("workspace-write") .with_root_config(r#"approvals_reviewer = "user""#) @@ -218,13 +219,14 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() let working_directory = tmp.path().join("workdir"); std::fs::create_dir(&working_directory)?; - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), - "call_sleep_approval", - )?]) - .await; + let server = + create_mock_responses_server_sequence(vec![create_command_execution_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep_approval", + )?]) + .await; MockResponsesConfig::new(&server.uri()) .with_approval_policy("on-request") .with_root_config(r#"approvals_reviewer = "user""#) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index d46ab18128..b264f66b72 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -3,14 +3,14 @@ use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; use app_test_support::create_apply_patch_sse_response; -use app_test_support::create_escalated_shell_command_sse_response; +use app_test_support::create_command_execution_sse_response; +use app_test_support::create_escalated_command_execution_sse_response; use app_test_support::create_exec_command_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_request_user_input_sse_response; -use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use app_test_support::write_models_cache; @@ -2242,7 +2242,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { let first_shell_command = vec![ "python3".to_string(), "-c".to_string(), - "import sys; print(sys.argv[1].endswith('7890'))".to_string(), + "import sys, time; time.sleep(0.5); print(sys.argv[1].endswith('7890'))".to_string(), format!("Authorization: Bearer {bearer_token}"), ]; let expected_approval_command = format_with_current_shell_display(&shlex::try_join( @@ -2254,14 +2254,14 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { // Mock server: first turn requests a shell call (elicitation), then completes. // Second turn same, but we'll set approval_policy=never to avoid elicitation. let responses = vec![ - create_escalated_shell_command_sse_response( + create_escalated_command_execution_sse_response( first_shell_command, /*workdir*/ None, Some(5000), "call1", )?, create_final_assistant_message_sse_response("done 1")?, - create_shell_command_sse_response( + create_command_execution_sse_response( vec![ "python3".to_string(), "-c".to_string(), @@ -2387,6 +2387,31 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { } } + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + assert!( + requests.iter().any(|request| { + request.url.path().ends_with("/responses") + && serde_json::from_slice::(&request.body) + .ok() + .and_then(|body| { + body["input"].as_array().map(|items| { + items.iter().any(|item| { + item["type"] == "function_call_output" + && item["call_id"] == "call1" + && item["output"] + .as_str() + .is_some_and(|output| output.contains("True")) + }) + }) + }) + .unwrap_or(false) + }), + "model request should include the command output confirming the original bearer token" + ); + // Second turn with approval_policy=never should not elicit approval let _: TurnStartResponse = mcp .request(|request_id| ClientRequest::TurnStart { @@ -2468,7 +2493,7 @@ async fn run_turn_start_exec_approval_rejection_v2( expected_approval_command.replace(bearer_token, "[REDACTED_SECRET]"); let responses = vec![ - create_escalated_shell_command_sse_response( + create_escalated_command_execution_sse_response( shell_command, /*workdir*/ None, Some(5000), @@ -2614,6 +2639,12 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns( let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); std::fs::create_dir(&codex_home)?; + let rules_dir = codex_home.join("rules"); + std::fs::create_dir(&rules_dir)?; + std::fs::write( + rules_dir.join("default.rules"), + r#"prefix_rule(pattern=["echo"], decision="allow")"#, + )?; let workspace_root = tmp.path().join("workspace"); std::fs::create_dir(&workspace_root)?; let first_cwd = workspace_root.join("turn1"); @@ -2622,14 +2653,14 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns( std::fs::create_dir(&second_cwd)?; let responses = vec![ - create_shell_command_sse_response( + create_command_execution_sse_response( vec!["echo".to_string(), "first".to_string(), "turn".to_string()], /*workdir*/ None, Some(5000), "call-first", )?, create_final_assistant_message_sse_response("done first")?, - create_shell_command_sse_response( + create_command_execution_sse_response( vec!["echo".to_string(), "second".to_string(), "turn".to_string()], /*workdir*/ None, Some(5000), @@ -4639,7 +4670,7 @@ async fn command_execution_notifications_include_trusted_plugin_id() -> Result<( }"#, )?; let responses = vec![ - create_shell_command_sse_response( + create_command_execution_sse_response( vec![ "/bin/sh".to_string(), script_path.to_string_lossy().into_owned(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index f3f0aa2eb2..f960491db1 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -9,11 +9,11 @@ use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; -use app_test_support::create_escalated_shell_command_sse_response; +use app_test_support::create_command_execution_sse_response; +use app_test_support::create_escalated_command_execution_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; -use app_test_support::create_shell_command_sse_response; use codex_app_server_protocol::CommandAction; use codex_app_server_protocol::CommandExecutionApprovalDecision; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; @@ -74,7 +74,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { let release_marker_escaped = release_marker.to_string_lossy().replace('\'', r#"'\''"#); let wait_for_interrupt = format!("while [ ! -f '{release_marker_escaped}' ]; do sleep 0.01; done"); - let response = create_shell_command_sse_response( + let response = create_command_execution_sse_response( vec!["/bin/sh".to_string(), "-c".to_string(), wait_for_interrupt], /*workdir*/ None, Some(5000), @@ -96,7 +96,8 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { "never", &BTreeMap::from([ (Feature::ShellZshFork, true), - (Feature::UnifiedExec, false), + (Feature::UnifiedExec, true), + (Feature::UnifiedExecZshFork, true), (Feature::ShellSnapshot, false), ]), )?; @@ -192,7 +193,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); let responses = vec![ - create_escalated_shell_command_sse_response( + create_escalated_command_execution_sse_response( vec![ "python3".to_string(), "-c".to_string(), @@ -211,7 +212,8 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { "on-request", &BTreeMap::from([ (Feature::ShellZshFork, true), - (Feature::UnifiedExec, false), + (Feature::UnifiedExec, true), + (Feature::UnifiedExecZshFork, true), (Feature::ShellSnapshot, false), ]), )?; @@ -323,7 +325,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { }; eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); - let responses = vec![create_escalated_shell_command_sse_response( + let responses = vec![create_escalated_command_execution_sse_response( vec![ "python3".to_string(), "-c".to_string(), @@ -340,7 +342,8 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { "on-request", &BTreeMap::from([ (Feature::ShellZshFork, true), - (Feature::UnifiedExec, false), + (Feature::UnifiedExec, true), + (Feature::UnifiedExecZshFork, true), (Feature::ShellSnapshot, false), ]), )?; @@ -466,15 +469,15 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() second_file.display() ); let tool_call_arguments = serde_json::to_string(&serde_json::json!({ - "command": shell_command, + "cmd": shell_command, "workdir": serde_json::Value::Null, - "timeout_ms": 20000 + "yield_time_ms": 20000 }))?; let response = responses::sse(vec![ responses::ev_response_created("resp-1"), responses::ev_function_call( "call-zsh-fork-subcommand-decline", - "shell_command", + "exec_command", &tool_call_arguments, ), responses::ev_completed("resp-1"), @@ -495,7 +498,8 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() "on-request", &BTreeMap::from([ (Feature::ShellZshFork, true), - (Feature::UnifiedExec, false), + (Feature::UnifiedExec, true), + (Feature::UnifiedExecZshFork, true), (Feature::ShellSnapshot, false), ]), )?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 240b66048b..7eac21362a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -3,9 +3,9 @@ use anyhow::Context; use anyhow::Result; use app_test_support::TestAppServer; +use app_test_support::create_command_execution_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; -use app_test_support::create_shell_command_sse_response; use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; @@ -124,14 +124,15 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { let working_directory = tmp.path().join("workdir"); std::fs::create_dir(&working_directory)?; - let server = - create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_command_execution_sse_response( shell_command.clone(), Some(&working_directory), Some(10_000), "call_sleep", - )?]) - .await; + )?, + ]) + .await; write_mock_responses_config_toml_with_chatgpt_base_url( &codex_home, &server.uri(), @@ -237,7 +238,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { std::fs::create_dir(&working_directory)?; let server = create_mock_responses_server_sequence_unchecked(vec![ - create_shell_command_sse_response( + create_command_execution_sse_response( shell_command.clone(), Some(&working_directory), Some(10_000), @@ -371,7 +372,7 @@ async fn turn_steer_rejects_context_only_input_without_merging_context() -> Resu std::fs::create_dir(&working_directory)?; let server = create_mock_responses_server_sequence_unchecked(vec![ - create_shell_command_sse_response( + create_command_execution_sse_response( vec!["sleep".to_string(), "1".to_string()], Some(&working_directory), Some(10_000), diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 3b7f74c4aa..bbed42a8c0 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -71,7 +71,7 @@ async fn models_client_hits_models_endpoint() { description: ReasoningEffort::High.to_string(), }, ], - shell_type: ConfigShellToolType::ShellCommand, + shell_type: ConfigShellToolType::UnifiedExec, visibility: ModelVisibility::List, supported_in_api: true, priority: 1, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 916e3d7565..f10f5f7831 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6445,6 +6445,50 @@ async fn legacy_toggles_map_to_features() -> std::io::Result<()> { Ok(()) } +#[tokio::test] +async fn legacy_unified_exec_disable_flags_do_not_disable_command_execution() -> std::io::Result<()> +{ + for cfg in [ + ConfigToml { + features: Some(FeaturesToml::from(BTreeMap::from([( + "unified_exec".to_string(), + false, + )]))), + ..Default::default() + }, + ConfigToml { + experimental_use_unified_exec_tool: Some(false), + ..Default::default() + }, + ] { + let codex_home = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(config.features.enabled(Feature::UnifiedExec)); + assert!(config.features.enabled(Feature::ShellTool)); + + config + .features + .disable(Feature::UnifiedExec) + .expect("legacy unified-exec toggle should normalize successfully"); + assert!(config.features.enabled(Feature::UnifiedExec)); + + config + .features + .disable(Feature::ShellTool) + .expect("shell tool should remain independently configurable"); + assert!(!config.features.enabled(Feature::ShellTool)); + assert!(config.features.enabled(Feature::UnifiedExec)); + } + + Ok(()) +} + #[tokio::test] async fn responses_websocket_features_do_not_change_wire_api() -> std::io::Result<()> { for feature_key in ["responses_websockets", "responses_websockets_v2"] { @@ -10977,6 +11021,38 @@ shell_tool = false Ok(()) } +#[tokio::test] +async fn feature_requirements_can_still_disable_unified_exec() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let mut config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +unified_exec = false +shell_tool = true +"#, + ), + ) + .build() + .await?; + + assert!(!config.features.enabled(Feature::UnifiedExec)); + assert!(config.features.enabled(Feature::ShellTool)); + + config + .features + .enable(Feature::UnifiedExec) + .expect("managed feature mutations should normalize successfully"); + + assert!(!config.features.enabled(Feature::UnifiedExec)); + assert!(config.features.enabled(Feature::ShellTool)); + + Ok(()) +} + #[tokio::test] async fn feature_requirements_auto_review_disables_guardian_approval() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index 234a2835c6..1a2b970021 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -152,6 +152,12 @@ fn normalize_candidate( mut candidate: Features, pinned_features: &BTreeMap, ) -> Features { + // Legacy user opt-outs selected the removed shell backend. Only managed + // requirements may disable the remaining unified-exec implementation. + if !pinned_features.contains_key(&Feature::UnifiedExec) { + candidate.enable(Feature::UnifiedExec); + } + for (feature, enabled) in pinned_features { candidate.set_enabled(*feature, *enabled); } diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 893b0cfc28..4c8c8ce80a 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -205,6 +205,7 @@ impl ExecExpiration { } /// If ExecExpiration is a timeout, returns the timeout in milliseconds. + #[cfg(target_os = "windows")] pub(crate) fn timeout_ms(&self) -> Option { match self { ExecExpiration::Timeout(duration) => Some(duration.as_millis() as u64), diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index 9ccd2426e9..0e8a4c0597 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -16,14 +16,6 @@ use super::prompt::guardian_truncate_text; #[derive(Debug, Clone, PartialEq)] pub(crate) enum GuardianApprovalRequest { - Shell { - id: String, - command: Vec, - cwd: AbsolutePathBuf, - sandbox_permissions: crate::sandboxing::SandboxPermissions, - additional_permissions: Option, - justification: Option, - }, ExecCommand { id: String, command: Vec, @@ -264,22 +256,6 @@ pub(crate) fn guardian_approval_request_to_json( action: &GuardianApprovalRequest, ) -> serde_json::Result { match action { - GuardianApprovalRequest::Shell { - id: _, - command, - cwd, - sandbox_permissions, - additional_permissions, - justification, - } => serialize_command_guardian_action( - "shell", - command, - cwd, - *sandbox_permissions, - additional_permissions.as_ref(), - justification.as_ref(), - /*tty*/ None, - ), GuardianApprovalRequest::ExecCommand { id: _, command, @@ -388,9 +364,6 @@ pub(crate) fn guardian_assessment_action( action: &GuardianApprovalRequest, ) -> GuardianAssessmentAction { match action { - GuardianApprovalRequest::Shell { command, cwd, .. } => { - command_assessment_action(GuardianCommandSource::Shell, command, cwd) - } GuardianApprovalRequest::ExecCommand { command, cwd, .. } => { command_assessment_action(GuardianCommandSource::UnifiedExec, command, cwd) } @@ -456,14 +429,6 @@ pub(crate) fn guardian_reviewed_action( request: &GuardianApprovalRequest, ) -> GuardianReviewedAction { match request { - GuardianApprovalRequest::Shell { - sandbox_permissions, - additional_permissions, - .. - } => GuardianReviewedAction::Shell { - sandbox_permissions: *sandbox_permissions, - additional_permissions: additional_permissions.clone(), - }, GuardianApprovalRequest::ExecCommand { sandbox_permissions, additional_permissions, @@ -514,8 +479,7 @@ pub(crate) fn guardian_reviewed_action( pub(crate) fn guardian_request_target_item_id(request: &GuardianApprovalRequest) -> Option<&str> { match request { - GuardianApprovalRequest::Shell { id, .. } - | GuardianApprovalRequest::ExecCommand { id, .. } + GuardianApprovalRequest::ExecCommand { id, .. } | GuardianApprovalRequest::ApplyPatch { id, .. } | GuardianApprovalRequest::McpToolCall { id, .. } | GuardianApprovalRequest::RequestPermissions { id, .. } => Some(id), @@ -532,8 +496,7 @@ pub(crate) fn guardian_request_turn_id<'a>( match request { GuardianApprovalRequest::NetworkAccess { turn_id, .. } | GuardianApprovalRequest::RequestPermissions { turn_id, .. } => turn_id, - GuardianApprovalRequest::Shell { .. } - | GuardianApprovalRequest::ExecCommand { .. } + GuardianApprovalRequest::ExecCommand { .. } | GuardianApprovalRequest::ApplyPatch { .. } | GuardianApprovalRequest::McpToolCall { .. } => default_turn_id, #[cfg(unix)] diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 46b04147e3..45efa6e746 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -77,8 +77,7 @@ fn plugin_attribution_for_guardian_request( request: &GuardianApprovalRequest, ) -> Option { match request { - GuardianApprovalRequest::Shell { command, cwd, .. } - | GuardianApprovalRequest::ExecCommand { command, cwd, .. } => { + GuardianApprovalRequest::ExecCommand { command, cwd, .. } => { turn.plugin_attribution_for_command(command, cwd) } #[cfg(unix)] diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 94a3229697..3a6090992b 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -1626,13 +1626,14 @@ mod tests { parent_session: Arc::new(session), parent_context: GuardianReviewContext::from(Arc::new(turn)), spawn_config, - request: GuardianApprovalRequest::Shell { + request: GuardianApprovalRequest::ExecCommand { id: "shell-1".to_string(), command: vec!["git".to_string(), "status".to_string()], cwd, sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Inspect repo state.".to_string()), + tty: false, }, reasons: ApprovalRequestReasons::default(), schema: super::super::prompt::guardian_output_schema(), diff --git a/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_followup_review_request_layout.snap b/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_followup_review_request_layout.snap index 09eadc8044..52414bcaf7 100644 --- a/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_followup_review_request_layout.snap +++ b/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_followup_review_request_layout.snap @@ -22,7 +22,7 @@ Scenario: Guardian follow-up review request layout [12] First retry reason\n\n [13] Assess the exact planned action below. Use read-only tool checks when local state matters.\n [14] Planned action JSON:\n - [15] {\n "command": [\n "git",\n "push"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the first docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "shell"\n}\n + [15] {\n "command": [\n "git",\n "push"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the first docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "exec_command",\n "tty": false\n}\n [16] >>> APPROVAL REQUEST END\n ## Follow-up Guardian Review Request @@ -43,7 +43,7 @@ Scenario: Guardian follow-up review request layout [12] First retry reason\n\n [13] Assess the exact planned action below. Use read-only tool checks when local state matters.\n [14] Planned action JSON:\n - [15] {\n "command": [\n "git",\n "push"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the first docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "shell"\n}\n + [15] {\n "command": [\n "git",\n "push"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the first docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "exec_command",\n "tty": false\n}\n [16] >>> APPROVAL REQUEST END\n 03:message/assistant:{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"first guardian rationale from the prior review"} 04:message/developer:Use prior reviews as context, not binding precedent. Follow the Workspace Policy. If the user explicitly approves a previously rejected action after being informed of the concrete risks, set outcome to "allow" unless the policy explicitly disallows user overwrites in such cases. @@ -60,7 +60,7 @@ Scenario: Guardian follow-up review request layout [10] Second retry reason\n\n [11] Assess the exact planned action below. Use read-only tool checks when local state matters.\n [12] Planned action JSON:\n - [13] {\n "command": [\n "git",\n "push",\n "--force-with-lease"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the second docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "shell"\n}\n + [13] {\n "command": [\n "git",\n "push",\n "--force-with-lease"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the second docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "exec_command",\n "tty": false\n}\n [14] >>> APPROVAL REQUEST END\n shared_prompt_cache_key: true diff --git a/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_review_request_layout.snap b/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_review_request_layout.snap index f5e10602d6..81681ccc8f 100644 --- a/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_review_request_layout.snap +++ b/codex-rs/core/src/guardian/snapshots/codex_core__guardian__tests__guardian_review_request_layout.snap @@ -23,5 +23,5 @@ Scenario: Guardian review request layout [13] Sandbox denied outbound git push to github.com.\n\n [14] Assess the exact planned action below. Use read-only tool checks when local state matters.\n [15] Planned action JSON:\n - [16] {\n "command": [\n "git",\n "push",\n "origin",\n "guardian-approval-mvp"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the reviewed docs fix to the repo remote.",\n "sandbox_permissions": "use_default",\n "tool": "shell"\n}\n + [16] {\n "command": [\n "git",\n "push",\n "origin",\n "guardian-approval-mvp"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the reviewed docs fix to the repo remote.",\n "sandbox_permissions": "use_default",\n "tool": "exec_command",\n "tty": false\n}\n [17] >>> APPROVAL REQUEST END\n diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index c0db8a93c0..300aeb123e 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -278,14 +278,15 @@ async fn guardian_test_session_turn_and_rx( (session, turn, rx) } -fn guardian_shell_request(id: &str) -> GuardianApprovalRequest { - GuardianApprovalRequest::Shell { +fn guardian_exec_command_request(id: &str) -> GuardianApprovalRequest { + GuardianApprovalRequest::ExecCommand { id: id.to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push the reviewed docs fix.".to_string()), + tty: false, } } @@ -482,13 +483,14 @@ async fn build_guardian_prompt_full_mode_preserves_initial_review_format() -> an let prompt = build_guardian_prompt_items( session.as_ref(), Some("Sandbox denied outbound git push to github.com.".to_string()), - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-1".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push the reviewed docs fix.".to_string()), + tty: false, }, GuardianPromptMode::Full, ) @@ -518,13 +520,14 @@ async fn build_guardian_prompt_prefers_retry_reason_over_approval_reason() -> an approval: Some("A policy rule requires approval.".to_string()), retry: Some("The sandbox blocked the initial command.".to_string()), }, - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-1".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: None, + tty: false, }, GuardianPromptMode::Full, /*reviewed_node_repl_evidence_sequence*/ 0, @@ -556,13 +559,14 @@ async fn build_guardian_prompt_truncates_oversized_approval_reason() -> anyhow:: approval: Some(approval_reason), retry: None, }, - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-1".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: None, + tty: false, }, GuardianPromptMode::Full, /*reviewed_node_repl_evidence_sequence*/ 0, @@ -646,13 +650,14 @@ async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Re approval: None, retry: Some("Sandbox denied reading /repo/private/secret.txt.".to_string()), }, - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-1".to_string(), command: vec!["cat".to_string(), "/repo/private/secret.txt".to_string()], cwd: test_path_buf("/repo").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::RequireEscalated, additional_permissions: None, justification: Some("Need to inspect the secret file.".to_string()), + tty: false, }, GuardianPromptMode::Full, /*reviewed_node_repl_evidence_sequence*/ 0, @@ -702,13 +707,14 @@ async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyh let prompt = build_guardian_prompt_items( session.as_ref(), /*retry_reason*/ None, - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-2".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push the second docs fix.".to_string()), + tty: false, }, GuardianPromptMode::Delta { cursor: GuardianTranscriptCursor { @@ -740,13 +746,14 @@ async fn build_guardian_prompt_delta_mode_handles_empty_delta() -> anyhow::Resul let prompt = build_guardian_prompt_items( session.as_ref(), /*retry_reason*/ None, - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-2".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push the second docs fix.".to_string()), + tty: false, }, GuardianPromptMode::Delta { cursor: GuardianTranscriptCursor { @@ -775,13 +782,14 @@ async fn build_guardian_prompt_stale_delta_cursor_falls_back_to_full_prompt() -> let prompt = build_guardian_prompt_items( session.as_ref(), /*retry_reason*/ None, - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-3".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push the docs fix.".to_string()), + tty: false, }, GuardianPromptMode::Delta { cursor: GuardianTranscriptCursor { @@ -860,13 +868,14 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - let prompt = build_guardian_prompt_items( session.as_ref(), /*retry_reason*/ None, - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-4".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push after the compaction.".to_string()), + tty: false, }, GuardianPromptMode::Delta { cursor: GuardianTranscriptCursor { @@ -1167,7 +1176,7 @@ async fn build_guardian_prompt_items_keeps_other_requests_generic() -> anyhow::R guardian_mcp_request("node_repl", "js"), guardian_mcp_request("node_repl", "inspect"), guardian_mcp_request("another_server", "js"), - guardian_shell_request("shell-1"), + guardian_exec_command_request("shell-1"), ] { let prompt = build_guardian_prompt_items_with_parent_turn( session.as_ref(), @@ -1732,13 +1741,14 @@ async fn guardian_request_model_for_auto_review( let (outcome, analytics_result) = run_guardian_review_session_for_test( Arc::clone(&session), turn, - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-1".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: None, + tty: false, }, ApprovalRequestReasons { approval: None, @@ -1970,7 +1980,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() ) .await; - let request = GuardianApprovalRequest::Shell { + let request = GuardianApprovalRequest::ExecCommand { id: "shell-1".to_string(), command: vec![ "git".to_string(), @@ -1982,6 +1992,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push the reviewed docs fix to the repo remote.".to_string()), + tty: false, }; let outcome = run_guardian_review_session_for_test( @@ -2107,13 +2118,14 @@ async fn build_guardian_prompt_items_includes_parent_session_id() -> anyhow::Res let prompt = build_guardian_prompt_items( &session, /*retry_reason*/ None, - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-1".to_string(), command: vec!["git".to_string(), "status".to_string()], cwd: test_path_buf("/repo").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: None, + tty: false, }, GuardianPromptMode::Full, ) @@ -2198,13 +2210,14 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: turn_mut.config = Arc::new(config); seed_guardian_parent_history(&session, &turn).await; - let first_request = GuardianApprovalRequest::Shell { + let first_request = GuardianApprovalRequest::ExecCommand { id: "shell-1".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push the first docs fix.".to_string()), + tty: false, }; let first_outcome = run_guardian_review_session_for_test( Arc::clone(&session), @@ -2244,7 +2257,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: ], ) .await; - let second_request = GuardianApprovalRequest::Shell { + let second_request = GuardianApprovalRequest::ExecCommand { id: "shell-2".to_string(), command: vec![ "git".to_string(), @@ -2255,6 +2268,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push the second docs fix.".to_string()), + tty: false, }; let second_outcome = run_guardian_review_session_for_test( Arc::clone(&session), @@ -2317,13 +2331,14 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: /*reference_context_item*/ None, ) .await; - let third_request = GuardianApprovalRequest::Shell { + let third_request = GuardianApprovalRequest::ExecCommand { id: "shell-3".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push the third docs fix.".to_string()), + tty: false, }; let third_outcome = run_guardian_review_session_for_test( Arc::clone(&session), @@ -2355,7 +2370,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: let fourth_outcome = run_guardian_review_session_for_test( Arc::clone(&session), Arc::clone(&turn), - guardian_shell_request("shell-4"), + guardian_exec_command_request("shell-4"), ApprovalRequestReasons::default(), guardian_output_schema(), /*external_cancel*/ None, @@ -2564,13 +2579,14 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow:: let first_outcome = run_guardian_review_session_for_test( Arc::clone(&session), Arc::clone(&turn), - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-1".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push the first docs fix.".to_string()), + tty: false, }, ApprovalRequestReasons::default(), guardian_output_schema(), @@ -2609,13 +2625,14 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow:: let second_outcome = run_guardian_review_session_for_test( Arc::clone(&session), Arc::clone(&turn), - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-2".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push the second docs fix.".to_string()), + tty: false, }, ApprovalRequestReasons::default(), guardian_output_schema(), @@ -2689,13 +2706,14 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> &session, &turn, "review-shell-guardian-error".to_string(), - GuardianApprovalRequest::Shell { + GuardianApprovalRequest::ExecCommand { id: "shell-guardian-error".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Need to push the reviewed docs fix.".to_string()), + tty: false, }, ApprovalRequestReasons::default(), ) @@ -2782,7 +2800,7 @@ async fn guardian_review_retries_transient_session_failure_then_approves() -> an let (outcome, metadata) = run_guardian_review_session_for_test( Arc::clone(&session), Arc::clone(&turn), - guardian_shell_request("shell-session-retry"), + guardian_exec_command_request("shell-session-retry"), ApprovalRequestReasons::default(), guardian_output_schema(), /*external_cancel*/ None, @@ -2824,7 +2842,7 @@ async fn guardian_review_does_not_retry_missing_assessment_payload() -> anyhow:: &session, &turn, "review-missing-assessment".to_string(), - guardian_shell_request("shell-missing-assessment"), + guardian_exec_command_request("shell-missing-assessment"), ApprovalRequestReasons::default(), ) .await; @@ -2873,7 +2891,7 @@ async fn guardian_review_retries_two_parse_failures_then_approves() -> anyhow::R let (outcome, metadata) = run_guardian_review_session_for_test( Arc::clone(&session), Arc::clone(&turn), - guardian_shell_request("shell-parse-retry"), + guardian_exec_command_request("shell-parse-retry"), ApprovalRequestReasons::default(), guardian_output_schema(), /*external_cancel*/ None, @@ -2928,7 +2946,7 @@ async fn guardian_review_exhausts_three_failures_with_one_terminal_event() -> an &session, &turn, "review-exhausted-retry".to_string(), - guardian_shell_request("shell-exhausted-retry"), + guardian_exec_command_request("shell-exhausted-retry"), ApprovalRequestReasons::default(), ) .await; @@ -2979,7 +2997,7 @@ async fn guardian_review_does_not_retry_valid_denial() -> anyhow::Result<()> { &session, &turn, "review-valid-denial".to_string(), - guardian_shell_request("shell-valid-denial"), + guardian_exec_command_request("shell-valid-denial"), ApprovalRequestReasons::default(), ) .await; @@ -3039,7 +3057,7 @@ async fn escalated_retry_bypasses_extension_approval_and_runs_guardian() -> anyh &session, &turn, "review-escalated-retry".to_string(), - guardian_shell_request("shell-escalated-retry"), + guardian_exec_command_request("shell-escalated-retry"), ApprovalRequestReasons { approval: None, retry: Some(retry_reason.to_string()), @@ -3136,13 +3154,14 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> let (session, turn) = guardian_test_session_and_turn_with_base_url(server.uri()).await; seed_guardian_parent_history(&session, &turn).await; - let initial_request = GuardianApprovalRequest::Shell { + let initial_request = GuardianApprovalRequest::ExecCommand { id: "shell-guardian-1".to_string(), command: vec!["git".to_string(), "status".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Inspect repo state before proceeding.".to_string()), + tty: false, }; assert_eq!( review_approval_request( @@ -3179,21 +3198,23 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> ) .await; - let second_request = GuardianApprovalRequest::Shell { + let second_request = GuardianApprovalRequest::ExecCommand { id: "shell-guardian-2".to_string(), command: vec!["git".to_string(), "diff".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Inspect pending changes before proceeding.".to_string()), + tty: false, }; - let third_request = GuardianApprovalRequest::Shell { + let third_request = GuardianApprovalRequest::ExecCommand { id: "shell-guardian-3".to_string(), command: vec!["git".to_string(), "push".to_string()], cwd: test_path_buf("/repo/codex-rs/core").abs(), sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: Some("Inspect whether pushing is safe before proceeding.".to_string()), + tty: false, }; let session_for_second = Arc::clone(&session); diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index 097c0cc117..84afe8fb84 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -4,7 +4,6 @@ use crate::tools::flat_tool_name; use crate::tools::handlers::unified_exec::ExecCommandArgs; use codex_memories_read::usage::MEMORIES_USAGE_METRIC; use codex_memories_read::usage::memories_usage_kinds_from_command; -use codex_protocol::models::ShellCommandToolCallParams; pub(crate) fn emit_metric_for_tool_read(invocation: &ToolInvocation, success: bool) { let Some(command) = shell_script_for_invocation(invocation) else { @@ -36,9 +35,6 @@ pub(crate) fn shell_script_for_invocation(invocation: &ToolInvocation) -> Option } match invocation.tool_name.name.as_str() { - "shell_command" => serde_json::from_str::(arguments) - .ok() - .map(|params| params.command), "exec_command" => serde_json::from_str::(arguments) .ok() .map(|params| params.cmd), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4233e5f94d..9a303e5ba6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1257,7 +1257,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an _req: &TurnEnvironment, call_id: &str, ) -> std::io::Result { - Ok(crate::tools::sandboxing::ApprovalAction::Shell { + Ok(crate::tools::sandboxing::ApprovalAction::ExecCommand { id: call_id.to_string(), environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), command: Vec::new(), @@ -1266,6 +1266,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, justification: None, + tty: false, proposed_execpolicy_amendment: None, }) } @@ -11089,7 +11090,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { id: None, status: None, call_id: "call-1".to_string(), - name: "shell_command".to_string(), + name: "exec_command".to_string(), namespace: None, input: "{}".to_string(), internal_chat_message_metadata_passthrough: None, @@ -11116,7 +11117,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { FunctionCallError::Fatal(message) => { assert_eq!( message, - "tool shell_command invoked with incompatible payload" + "tool exec_command invoked with incompatible payload" ); } other => panic!("expected FunctionCallError::Fatal, got {other:?}"), @@ -11300,77 +11301,6 @@ async fn sample_rollout( ) } -#[cfg(unix)] -#[tokio::test] -async fn shell_tool_cancellation_waits_for_runtime_cleanup() -> anyhow::Result<()> { - let session = make_session_with_config(|config| { - let cwd = config.cwd.clone(); - config - .permissions - .set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path()) - .expect("test setup should allow sandbox policy"); - }) - .await?; - let turn_context = session.new_default_turn().await; - let session = Arc::new(session); - let turn_context = Arc::new(turn_context); - let temp_dir = tempfile::TempDir::new()?; - let ready_marker = temp_dir.path().join("ready"); - let cleanup_marker = temp_dir.path().join("cleanup"); - // Interrupt after the shell starts, then verify dispatch waits for its TERM cleanup trap. - let command = format!( - r#"trap 'printf cleaned > "{}"; exit 0' TERM -printf ready > "{}" -while :; do sleep 1; done"#, - cleanup_marker.display(), - ready_marker.display(), - ); - let item = ResponseItem::FunctionCall { - id: None, - name: "shell_command".to_string(), - namespace: None, - arguments: serde_json::json!({ - "command": command, - "timeout_ms": 60_000, - }) - .to_string(), - call_id: "shell-cleanup-call".to_string(), - encrypted_function_args: None, - internal_chat_message_metadata_passthrough: None, - }; - let call = ToolRouter::build_tool_call(item)? - .expect("shell command response item should build a tool call"); - let cancellation_token = CancellationToken::new(); - let cancellation_tx = cancellation_token.clone(); - let handle = tokio::spawn( - test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context)) - .handle_tool_call(call, cancellation_token), - ); - - let mut ready = false; - for _ in 0..50 { - if ready_marker.exists() { - ready = true; - break; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - if !ready { - cancellation_tx.cancel(); - let _ = timeout(Duration::from_secs(5), handle).await; - anyhow::bail!("shell command should reach the ready marker"); - } - - cancellation_tx.cancel(); - timeout(Duration::from_secs(5), handle) - .await - .expect("cancelled shell tool should finish promptly") - .expect("shell tool task should join") - .expect("cancelled shell tool should return a response item"); - assert_eq!(std::fs::read_to_string(cleanup_marker)?, "cleaned"); - Ok(()) -} - #[tokio::test] async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() { use crate::sandboxing::SandboxPermissions; diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 67133adb86..39befb985f 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -308,7 +308,7 @@ async fn request_permissions_guardian_review_stops_when_cancelled() { } #[tokio::test] -async fn guardian_allows_shell_command_additional_permissions_requests_past_policy_validation() { +async fn guardian_allows_exec_command_additional_permissions_requests_past_policy_validation() { let server = start_mock_server().await; let _request_log = mount_sse_once( &server, @@ -371,11 +371,9 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli ); let session = Arc::new(session); let turn_context = Arc::new(turn_context_raw); - let expiration_ms: u64 = if cfg!(windows) { 2_500 } else { 1_000 }; + let yield_time_ms: u64 = 10_000; - let handler = crate::tools::handlers::ShellCommandHandler::from( - codex_tools::ShellCommandBackendConfig::Classic, - ); + let handler = crate::tools::handlers::ExecCommandHandler::default(); #[allow(deprecated)] let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let step_context = StepContext::for_test(Arc::clone(&turn_context)); @@ -387,14 +385,14 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "test-call".to_string(), - tool_name: codex_tools::ToolName::plain("shell_command"), + tool_name: codex_tools::ToolName::plain("exec_command"), source: crate::tools::context::ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": "echo hi", + "cmd": "echo hi", "login": false, "workdir": workdir, - "timeout_ms": expiration_ms, + "yield_time_ms": yield_time_ms, "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, "additional_permissions": PermissionProfile { network: Some(NetworkPermissions { @@ -414,7 +412,7 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli } #[tokio::test] -async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_skip() { +async fn strict_auto_review_turn_grant_forces_guardian_for_exec_command_policy_skip() { let server = start_mock_server().await; let guardian_request_log = mount_sse_once( &server, @@ -501,9 +499,7 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_ ) .await; - let handler = crate::tools::handlers::ShellCommandHandler::from( - codex_tools::ShellCommandBackendConfig::Classic, - ); + let handler = crate::tools::handlers::ExecCommandHandler::default(); #[allow(deprecated)] let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let step_context = StepContext::for_test(Arc::clone(&turn_context)); @@ -515,14 +511,14 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_ cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "strict-shell-command-call".to_string(), - tool_name: codex_tools::ToolName::plain("shell_command"), + tool_name: codex_tools::ToolName::plain("exec_command"), source: ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": "echo hi", + "cmd": "echo hi", "login": false, "workdir": workdir, - "timeout_ms": 1_000_u64, + "yield_time_ms": 10_000_u64, }) .to_string(), }, @@ -665,7 +661,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message clippy::await_holding_invalid_type, reason = "test mutates active turn state directly to seed granted permissions" )] -async fn shell_command_allows_sticky_turn_permissions_without_inline_request_permissions_feature() { +async fn exec_command_allows_sticky_turn_permissions_without_inline_request_permissions_feature() { let (mut session, turn_context_raw) = make_session_and_context().await; session .features @@ -690,9 +686,7 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per let session = Arc::new(session); let turn_context = Arc::new(turn_context_raw); - let handler = crate::tools::handlers::ShellCommandHandler::from( - codex_tools::ShellCommandBackendConfig::Classic, - ); + let handler = crate::tools::handlers::ExecCommandHandler::default(); #[allow(deprecated)] let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let step_context = StepContext::for_test(Arc::clone(&turn_context)); @@ -704,13 +698,13 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per cancellation_token: CancellationToken::new(), tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), call_id: "sticky-turn-grant".to_string(), - tool_name: codex_tools::ToolName::plain("shell_command"), + tool_name: codex_tools::ToolName::plain("exec_command"), source: crate::tools::context::ToolCallSource::Direct, payload: ToolPayload::Function { arguments: serde_json::json!({ - "command": "echo hi", + "cmd": "echo hi", "login": false, - "timeout_ms": 1_000_u64, + "yield_time_ms": 10_000_u64, "workdir": workdir, }) .to_string(), diff --git a/codex-rs/core/src/tools/approvals.rs b/codex-rs/core/src/tools/approvals.rs index 23382bd1fa..780a93b74e 100644 --- a/codex-rs/core/src/tools/approvals.rs +++ b/codex-rs/core/src/tools/approvals.rs @@ -18,7 +18,6 @@ use crate::session::turn_context::TurnContext; use crate::tools::flat_tool_name; use crate::tools::hook_names::HookToolName; use crate::tools::runtimes::apply_patch::ApplyPatchApprovalKey; -use crate::tools::runtimes::shell::ApprovalKey; use crate::tools::runtimes::unified_exec::UnifiedExecApprovalKey; use crate::tools::sandboxing::ApprovalRequestReasons; use crate::tools::sandboxing::PermissionRequestPayload; @@ -65,17 +64,6 @@ pub(crate) struct ApprovalContext { #[derive(Clone, Debug, PartialEq)] pub(crate) enum ApprovalAction { - Shell { - id: String, - environment_id: String, - command: Vec, - hook_command: String, - cwd: PathUri, - sandbox_permissions: SandboxPermissions, - additional_permissions: Option, - justification: Option, - proposed_execpolicy_amendment: Option, - }, ExecCommand { id: String, environment_id: String, @@ -153,7 +141,6 @@ pub(crate) enum ApprovalAction { #[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Serialize)] #[serde(untagged)] pub(crate) enum ApprovalCacheKey { - Shell(ApprovalKey), ExecCommand(UnifiedExecApprovalKey), ApplyPatch(ApplyPatchApprovalKey), } @@ -161,12 +148,7 @@ pub(crate) enum ApprovalCacheKey { impl ApprovalAction { pub(crate) fn permission_request_payload(&self) -> PermissionRequestPayload { match self { - Self::Shell { - hook_command, - justification, - .. - } - | Self::ExecCommand { + Self::ExecCommand { hook_command, justification, .. @@ -214,20 +196,6 @@ impl ApprovalAction { pub(crate) fn cache_keys(&self) -> Vec { match self { - Self::Shell { - environment_id, - command, - cwd, - sandbox_permissions, - additional_permissions, - .. - } => vec![ApprovalCacheKey::Shell(ApprovalKey { - environment_id: environment_id.clone(), - command: canonicalize_command_for_approval(command), - cwd: cwd.clone(), - sandbox_permissions: *sandbox_permissions, - additional_permissions: additional_permissions.clone(), - })], Self::ExecCommand { environment_id, command, @@ -269,23 +237,6 @@ impl ApprovalAction { fn into_guardian_request(self) -> std::io::Result { Ok(match self { - Self::Shell { - id, - environment_id, - command, - cwd, - sandbox_permissions, - additional_permissions, - justification, - .. - } => crate::guardian::GuardianApprovalRequest::Shell { - id, - command, - cwd: guardian_cwd(&environment_id, cwd)?, - sandbox_permissions, - additional_permissions, - justification, - }, Self::ExecCommand { id, environment_id, @@ -663,16 +614,7 @@ impl Session { ctx: &ApprovalContext, ) -> ReviewDecision { match action { - ApprovalAction::Shell { - environment_id, - command, - cwd, - additional_permissions, - justification, - proposed_execpolicy_amendment, - .. - } - | ApprovalAction::ExecCommand { + ApprovalAction::ExecCommand { environment_id, command, cwd, @@ -690,18 +632,7 @@ impl Session { )); } }; - let tool_name = match action { - ApprovalAction::Shell { .. } => "shell", - ApprovalAction::ExecCommand { .. } => "unified_exec", - #[cfg(unix)] - ApprovalAction::Execve { .. } => unreachable!("matched command approval"), - ApprovalAction::ApplyPatch { .. } => unreachable!("matched command approval"), - ApprovalAction::McpToolCall { .. } - | ApprovalAction::NetworkAccess { .. } - | ApprovalAction::RequestPermissions { .. } => { - unreachable!("matched command approval") - } - }; + let tool_name = "unified_exec"; let reason = ctx .retry_reason .clone() diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 70d77c4298..d1b12b0544 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -367,7 +367,7 @@ impl ToolOutput for ExecCommandToolOutput { } Some(JsonValue::String( - self.truncated_output(self.model_output_max_tokens()), + self.truncated_output_with_policy(self.model_output_policy()), )) } @@ -405,13 +405,21 @@ impl ToolOutput for ExecCommandToolOutput { } impl ExecCommandToolOutput { - fn model_output_max_tokens(&self) -> usize { - resolve_max_tokens(self.max_output_tokens).min(self.truncation_policy.token_budget()) + fn model_output_policy(&self) -> TruncationPolicy { + let requested_policy = TruncationPolicy::Tokens(resolve_max_tokens(self.max_output_tokens)); + if requested_policy.byte_budget() < self.truncation_policy.byte_budget() { + requested_policy + } else { + self.truncation_policy + } } pub(crate) fn truncated_output(&self, max_tokens: usize) -> String { + self.truncated_output_with_policy(TruncationPolicy::Tokens(max_tokens)) + } + + fn truncated_output_with_policy(&self, policy: TruncationPolicy) -> String { let text = String::from_utf8_lossy(&self.raw_output).to_string(); - let policy = TruncationPolicy::Tokens(max_tokens); let Some(omitted_bytes) = self.output_omitted_bytes else { return formatted_truncate_text(&text, policy); }; @@ -462,9 +470,30 @@ impl ExecCommandToolOutput { } sections.push("Output:".to_string()); - sections.push(self.truncated_output(self.model_output_max_tokens())); + let header = sections.join("\n"); + let output_budget = (self.truncation_policy * 1.2) + .byte_budget() + .saturating_sub(header.len().saturating_add(/*rhs*/ 1)); + let mut policy = self.model_output_policy(); + let mut output = self.truncated_output_with_policy(policy); - sections.join("\n") + // History applies this same serialization budget to the complete response. + // Reserve room for metadata, warning headers, and the truncation marker so + // it does not truncate an already-truncated output a second time. + while output.len() > output_budget && policy.byte_budget() > 0 { + let excess_bytes = output.len() - output_budget; + policy = match policy { + TruncationPolicy::Bytes(bytes) => { + TruncationPolicy::Bytes(bytes.saturating_sub(excess_bytes)) + } + TruncationPolicy::Tokens(tokens) => TruncationPolicy::Tokens( + tokens.saturating_sub(TruncationPolicy::Bytes(excess_bytes).token_budget()), + ), + }; + output = self.truncated_output_with_policy(policy); + } + + format!("{header}\n{output}") } } diff --git a/codex-rs/core/src/tools/context_tests.rs b/codex-rs/core/src/tools/context_tests.rs index 511705ffe6..a13527f5cf 100644 --- a/codex-rs/core/src/tools/context_tests.rs +++ b/codex-rs/core/src/tools/context_tests.rs @@ -467,6 +467,52 @@ fn exec_command_tool_output_formats_truncated_response() { } } +#[test] +fn exec_command_tool_output_reserves_metadata_budget_and_preserves_policy_units() { + let payload = ToolPayload::Function { + arguments: "{}".to_string(), + }; + let raw_output = (1..=150) + .map(|line| format!("{line}\n")) + .collect::() + .into_bytes(); + + for (policy, marker) in [ + (TruncationPolicy::Bytes(200), "chars truncated"), + (TruncationPolicy::Tokens(50), "tokens truncated"), + ] { + let response = ExecCommandToolOutput { + event_call_id: "call-42".to_string(), + chunk_id: "abc123".to_string(), + wall_time: std::time::Duration::from_millis(/*millis*/ 1250), + raw_output: raw_output.clone(), + truncation_policy: policy, + max_output_tokens: None, + process_id: None, + exit_code: Some(0), + original_token_count: Some(123), + output_omitted_bytes: None, + hook_command: None, + } + .to_response_item("call-42", &payload); + + let ResponseInputItem::FunctionCallOutput { output, .. } = response else { + panic!("expected FunctionCallOutput"); + }; + let text = output + .body + .to_text() + .expect("exec output should serialize as text"); + + assert!(text.len() <= (policy * 1.2).byte_budget()); + assert_eq!(text.matches(marker).count(), 1); + assert!(text.contains("Original token count: 123")); + assert!(text.contains("Total output lines: 150")); + assert!(text.contains("\n1\n2\n3\n")); + assert!(text.ends_with("149\n150\n")); + } +} + #[test] fn exec_command_tool_output_preserves_omission_metadata_when_truncated() { let payload = ToolPayload::Function { diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index f98d44b1bc..5921000fe2 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -26,7 +26,6 @@ use codex_protocol::protocol::FileChange; use codex_protocol::protocol::PatchApplyStatus; use codex_protocol::protocol::TurnDiffEvent; use codex_shell_command::parse_command::parse_command; -use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use codex_utils_string::truncate_middle_with_token_budget; use std::collections::HashMap; @@ -180,13 +179,6 @@ async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, exec_input: &ExecCommand } // Concrete, allocation-free emitter: avoid trait objects and boxed futures. pub(crate) enum ToolEmitter { - Shell { - command: Vec, - cwd: PathUri, - source: ExecCommandSource, - parsed_cmd: Vec, - plugin_attribution: Option, - }, ApplyPatch { changes: HashMap, auto_approved: bool, @@ -203,22 +195,6 @@ pub(crate) enum ToolEmitter { } impl ToolEmitter { - pub fn shell( - command: Vec, - cwd: AbsolutePathBuf, - source: ExecCommandSource, - plugin_attribution: Option, - ) -> Self { - let parsed_cmd = parse_command(&command); - Self::Shell { - command, - cwd: PathUri::from_abs_path(&cwd), - source, - parsed_cmd, - plugin_attribution, - } - } - pub fn apply_patch_for_environment( changes: HashMap, auto_approved: bool, @@ -251,33 +227,6 @@ impl ToolEmitter { pub async fn emit(&self, ctx: ToolEventCtx<'_>, stage: ToolEventStage<'_>) { match (self, stage) { - ( - Self::Shell { - command, - cwd, - source, - parsed_cmd, - plugin_attribution, - .. - }, - stage, - ) => { - emit_exec_stage( - ctx, - ExecCommandInput::new( - command, - cwd, - parsed_cmd, - *source, - /*interaction_input*/ None, - /*process_id*/ None, - plugin_attribution.as_ref(), - ), - stage, - ) - .await; - } - ( Self::ApplyPatch { changes, @@ -491,9 +440,7 @@ impl ToolEmitter { // TODO: We should add a new ToolError variant for user-declined approvals. let normalized = if msg == "rejected by user" { match self { - Self::Shell { .. } | Self::UnifiedExec { .. } => { - "exec command rejected by user".to_string() - } + Self::UnifiedExec { .. } => "exec command rejected by user".to_string(), Self::ApplyPatch { .. } => "patch rejected by user".to_string(), } } else { diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 07f192013b..1217131be2 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -24,7 +24,6 @@ pub(crate) mod request_plugin_install_spec; mod request_user_input; pub(crate) mod request_user_input_spec; mod send_user_message_async; -mod shell; pub(crate) mod shell_spec; mod sleep; mod test_sync; @@ -70,8 +69,6 @@ pub use request_permissions::RequestPermissionsHandler; pub use request_plugin_install::RequestPluginInstallHandler; pub use request_user_input::RequestUserInputHandler; pub use send_user_message_async::SendUserMessageAsyncHandler; -pub use shell::ShellCommandHandler; -pub(crate) use shell::ShellCommandHandlerOptions; pub use sleep::SleepHandler; pub use test_sync::TestSyncHandler; pub(crate) use tool_search::ToolSearchHandlerCache; @@ -156,18 +153,6 @@ where parse_arguments(arguments) } -fn resolve_workdir_base_path( - arguments: &str, - default_cwd: &AbsolutePathBuf, -) -> Result { - let arguments: Value = parse_arguments(arguments)?; - Ok(arguments - .get("workdir") - .and_then(Value::as_str) - .filter(|workdir| !workdir.is_empty()) - .map_or_else(|| default_cwd.clone(), |workdir| default_cwd.join(workdir))) -} - fn resolve_tool_environment<'a>( environments: &'a TurnEnvironmentSnapshot, environment_id: Option<&str>, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs deleted file mode 100644 index accd129e33..0000000000 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ /dev/null @@ -1,252 +0,0 @@ -use codex_features::Feature; -use codex_protocol::models::ShellCommandToolCallParams; -use serde_json::Value as JsonValue; -use std::sync::Arc; -use tokio_util::sync::CancellationToken; - -use crate::exec::ExecParams; -use crate::exec_policy::ExecApprovalRequest; -use crate::function_tool::FunctionCallError; -use crate::session::step_context::StepContext; -use crate::session::turn_context::TurnEnvironment; -use crate::shell::ShellType; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolPayload; -use crate::tools::events::ToolEmitter; -use crate::tools::events::ToolEventCtx; -use crate::tools::handlers::apply_granted_turn_permissions; -use crate::tools::handlers::apply_patch::intercept_apply_patch; -use crate::tools::handlers::implicit_granted_permissions; -use crate::tools::handlers::normalize_and_validate_additional_permissions; -use crate::tools::handlers::parse_arguments; -use crate::tools::orchestrator::ToolOrchestrator; -use crate::tools::runtimes::shell::ShellRequest; -use crate::tools::runtimes::shell::ShellRuntime; -use crate::tools::runtimes::shell::ShellRuntimeBackend; -use crate::tools::sandboxing::ToolCtx; -use codex_core_plugins::strip_output_env; -use codex_protocol::models::AdditionalPermissionProfile; -use codex_protocol::protocol::ExecCommandSource; -use codex_tools::ToolName; -use codex_utils_path_uri::PathUri; - -mod shell_command; - -pub use shell_command::ShellCommandHandler; -pub(crate) use shell_command::ShellCommandHandlerOptions; - -fn shell_command_payload_command(payload: &ToolPayload) -> Option { - let ToolPayload::Function { arguments } = payload else { - return None; - }; - - parse_arguments::(arguments) - .ok() - .map(|params| params.command) -} - -struct RunExecLikeArgs { - tool_name: ToolName, - exec_params: ExecParams, - cancellation_token: CancellationToken, - hook_command: String, - shell_type: Option, - additional_permissions: Option, - prefix_rule: Option>, - session: Arc, - step_context: Arc, - turn_environment: TurnEnvironment, - tracker: crate::tools::context::SharedTurnDiffTracker, - call_id: String, - shell_runtime_backend: ShellRuntimeBackend, -} - -async fn run_exec_like(args: RunExecLikeArgs) -> Result { - let RunExecLikeArgs { - tool_name, - exec_params, - cancellation_token, - hook_command, - shell_type, - additional_permissions, - prefix_rule, - session, - step_context, - turn_environment, - tracker, - call_id, - shell_runtime_backend, - } = args; - let turn = Arc::clone(&step_context.turn); - - let fs = turn_environment.environment.get_filesystem(); - - let mut explicit_env_overrides = turn_environment.shell_environment_policy().r#set.clone(); - let mut env = exec_params.env.clone(); - strip_output_env(&mut env); - strip_output_env(&mut explicit_env_overrides); - let exec_permission_approvals_enabled = - session.features().enabled(Feature::ExecPermissionApprovals); - let requested_additional_permissions = additional_permissions.clone(); - let effective_additional_permissions = apply_granted_turn_permissions( - session.as_ref(), - &turn_environment.selection.environment_id, - exec_params.cwd.as_path(), - exec_params.sandbox_permissions, - additional_permissions, - ) - .await; - let additional_permissions_allowed = exec_permission_approvals_enabled - || (session.features().enabled(Feature::RequestPermissionsTool) - && effective_additional_permissions.permissions_preapproved); - let normalized_additional_permissions = implicit_granted_permissions( - exec_params.sandbox_permissions, - requested_additional_permissions.as_ref(), - &effective_additional_permissions, - ) - .map_or_else( - || { - normalize_and_validate_additional_permissions( - additional_permissions_allowed, - turn.approval_policy(), - effective_additional_permissions.sandbox_permissions, - effective_additional_permissions.additional_permissions, - effective_additional_permissions.permissions_preapproved, - &exec_params.cwd, - ) - }, - |permissions| Ok(Some(permissions)), - ) - .map_err(FunctionCallError::RespondToModel)?; - - // Approval policy guard for explicit escalation in non-OnRequest modes. - // Sticky turn permissions have already been approved, so they should - // continue through the normal exec approval flow for the command. - if effective_additional_permissions - .sandbox_permissions - .requests_sandbox_override() - && !effective_additional_permissions.permissions_preapproved - && !matches!( - turn.approval_policy(), - codex_protocol::protocol::AskForApproval::OnRequest - ) - { - let approval_policy = turn.approval_policy(); - return Err(FunctionCallError::RespondToModel(format!( - "approval policy is {approval_policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {approval_policy:?}" - ))); - } - - // Intercept apply_patch if present. - let apply_patch_cwd = PathUri::from_abs_path(&exec_params.cwd); - if let Some(output) = intercept_apply_patch( - &exec_params.command, - &apply_patch_cwd, - fs.as_ref(), - turn_environment.clone(), - session.clone(), - Arc::clone(&step_context), - Some(&tracker), - &call_id, - tool_name.name.as_str(), - ) - .await? - { - return Ok(output); - } - - let source = ExecCommandSource::Agent; - let plugin_attribution = - turn.plugin_attribution_for_command(&exec_params.command, &exec_params.cwd); - let emitter = ToolEmitter::shell( - exec_params.command.clone(), - exec_params.cwd.clone(), - source, - plugin_attribution, - ); - let event_ctx = ToolEventCtx::new( - session.as_ref(), - turn.as_ref(), - &call_id, - /*turn_diff_tracker*/ None, - ); - emitter.begin(event_ctx).await; - - let exec_approval_requirement = session - .services - .exec_policy - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &exec_params.command, - approval_policy: turn.approval_policy(), - permission_profile: turn_environment.permission_profile().clone(), - environment_policy: turn_environment.config().exec_policy.as_ref(), - windows_sandbox_level: turn.windows_sandbox_level, - sandbox_permissions: if effective_additional_permissions.permissions_preapproved { - codex_protocol::models::SandboxPermissions::UseDefault - } else { - effective_additional_permissions.sandbox_permissions - }, - prefix_rule, - allow_prefix_rules: turn.allow_prefix_rules(), - }) - .await; - - let req = ShellRequest { - command: exec_params.command.clone(), - turn_environment: turn_environment.clone(), - shell_type, - hook_command, - cwd: exec_params.cwd.clone(), - timeout_ms: exec_params.expiration.timeout_ms(), - cancellation_token, - env, - explicit_env_overrides, - network: exec_params.network.clone(), - sandbox_permissions: effective_additional_permissions.sandbox_permissions, - additional_permissions: normalized_additional_permissions, - #[cfg(unix)] - additional_permissions_preapproved: effective_additional_permissions - .permissions_preapproved, - justification: exec_params.justification.clone(), - exec_approval_requirement, - }; - let mut orchestrator = ToolOrchestrator::new(); - let mut runtime = ShellRuntime::for_shell_command(shell_runtime_backend); - let tool_ctx = ToolCtx { - session: session.clone(), - step_context, - call_id: call_id.clone(), - tool_name, - }; - let out = orchestrator - .run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy()) - .await - .map(|result| result.output); - let event_ctx = ToolEventCtx::new( - session.as_ref(), - turn.as_ref(), - &call_id, - /*turn_diff_tracker*/ None, - ); - let post_tool_use_response = out - .as_ref() - .ok() - .map(|output| { - crate::tools::format_exec_output_str(output, turn.model_info.truncation_policy.into()) - }) - .map(JsonValue::String); - let content = emitter - .finish(event_ctx, out, /*applied_patch_delta*/ None) - .await?; - Ok(FunctionToolOutput { - body: vec![ - codex_protocol::models::FunctionCallOutputContentItem::InputText { text: content }, - ], - success: Some(true), - post_tool_use_response, - }) -} - -#[cfg(test)] -#[path = "shell_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs deleted file mode 100644 index 2dbbc92668..0000000000 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ /dev/null @@ -1,306 +0,0 @@ -use codex_exec_server::LOCAL_ENVIRONMENT_ID; -use codex_protocol::models::ShellCommandToolCallParams; -use codex_tools::ShellCommandBackendConfig; -use codex_tools::ToolName; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_path_uri::PathUri; - -use crate::exec::ExecCapturePolicy; -use crate::exec::ExecParams; -use crate::exec_env::create_env; -use crate::exec_env::inject_apply_patch_env; -use crate::exec_env::inject_permission_profile_env; -use crate::exec_env::inject_session_id_env; -use crate::function_tool::FunctionCallError; -use crate::maybe_emit_implicit_skill_invocation; -use crate::session::turn_context::TurnContext; -use crate::session::turn_context::TurnEnvironment; -use crate::shell::Shell; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::context::boxed_tool_output; -use crate::tools::handlers::parse_arguments_with_base_path; -use crate::tools::handlers::resolve_sandbox_permissions; -use crate::tools::handlers::resolve_workdir_base_path; -use crate::tools::handlers::rewrite_function_string_argument; -use crate::tools::handlers::updated_hook_command; -use crate::tools::hook_names::HookToolName; -use crate::tools::registry::CoreToolRuntime; -use crate::tools::registry::PostToolUsePayload; -use crate::tools::registry::PreToolUsePayload; -use crate::tools::registry::ToolExecutor; -use crate::tools::runtimes::shell::ShellRuntimeBackend; -use codex_tools::ToolSpec; - -use super::super::shell_spec::CommandToolOptions; -use super::super::shell_spec::create_shell_command_tool; -use super::RunExecLikeArgs; -use super::run_exec_like; -use super::shell_command_payload_command; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ShellCommandBackend { - Classic, - ZshFork, -} - -pub struct ShellCommandHandler { - backend: ShellCommandBackend, - options: ShellCommandHandlerOptions, -} - -#[derive(Clone, Copy)] -pub(crate) struct ShellCommandHandlerOptions { - pub(crate) backend_config: ShellCommandBackendConfig, - pub(crate) allow_login_shell: bool, - pub(crate) exec_permission_approvals_enabled: bool, -} - -impl ShellCommandHandler { - pub(crate) fn new(options: ShellCommandHandlerOptions) -> Self { - let backend = match options.backend_config { - ShellCommandBackendConfig::Classic => ShellCommandBackend::Classic, - ShellCommandBackendConfig::ZshFork => ShellCommandBackend::ZshFork, - }; - Self { backend, options } - } - - fn shell_runtime_backend(&self) -> ShellRuntimeBackend { - match self.backend { - ShellCommandBackend::Classic => ShellRuntimeBackend::ShellCommandClassic, - ShellCommandBackend::ZshFork => ShellRuntimeBackend::ShellCommandZshFork, - } - } - - pub(super) fn resolve_use_login_shell( - login: Option, - allow_login_shell: bool, - ) -> Result { - if !allow_login_shell && login == Some(true) { - return Err(FunctionCallError::RespondToModel( - "login shell is disabled by config; omit `login` or set it to false.".to_string(), - )); - } - - Ok(login.unwrap_or(allow_login_shell)) - } - - pub(super) fn base_command(shell: &Shell, command: &str, use_login_shell: bool) -> Vec { - shell.derive_exec_args(command, use_login_shell) - } - - pub(super) fn to_exec_params( - params: &ShellCommandToolCallParams, - session: &crate::session::session::Session, - turn_context: &TurnContext, - turn_environment: &TurnEnvironment, - cwd: AbsolutePathBuf, - ) -> Result { - let session_shell = session.user_shell(); - let shell = turn_environment - .shell - .as_ref() - .unwrap_or(session_shell.as_ref()); - let use_login_shell = Self::resolve_use_login_shell( - params.login, - turn_environment.config().allow_login_shell, - )?; - let command = Self::base_command(shell, ¶ms.command, use_login_shell); - - let mut env = create_env( - turn_environment.shell_environment_policy(), - Some(session.thread_id), - ); - inject_session_id_env(&mut env, session.session_id()); - inject_apply_patch_env(&mut env, &turn_context.config.features); - let active_permission_profile = turn_environment.active_permission_profile(); - inject_permission_profile_env(&mut env, active_permission_profile.as_ref()); - let sandbox_permissions = resolve_sandbox_permissions( - params.sandbox_permissions, - params.justification.as_deref(), - )?; - - Ok(ExecParams { - command, - cwd, - expiration: params.timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - env, - network: turn_context.network.clone(), - network_environment_id: Some(turn_environment.selection.environment_id.clone()), - sandbox_permissions, - windows_sandbox_level: turn_context.windows_sandbox_level, - windows_sandbox_private_desktop: turn_context - .config - .permissions - .windows_sandbox_private_desktop, - justification: params.justification.clone(), - arg0: None, - }) - } -} - -impl From for ShellCommandHandler { - fn from(backend_config: ShellCommandBackendConfig) -> Self { - Self::new(ShellCommandHandlerOptions { - backend_config, - allow_login_shell: false, - exec_permission_approvals_enabled: false, - }) - } -} - -impl ToolExecutor for ShellCommandHandler { - fn tool_name(&self) -> ToolName { - ToolName::plain("shell_command") - } - - fn spec(&self) -> ToolSpec { - create_shell_command_tool(CommandToolOptions { - allow_login_shell: self.options.allow_login_shell, - exec_permission_approvals_enabled: self.options.exec_permission_approvals_enabled, - }) - } - - fn supports_parallel_tool_calls(&self) -> bool { - true - } - - fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { - Box::pin(self.handle_call(invocation)) - } -} - -impl ShellCommandHandler { - async fn handle_call( - &self, - invocation: ToolInvocation, - ) -> Result, FunctionCallError> { - let ToolInvocation { - session, - turn, - step_context, - cancellation_token, - tracker, - call_id, - payload, - .. - } = invocation; - - let tool_name = self.tool_name(); - let ToolPayload::Function { arguments } = payload else { - return Err(FunctionCallError::RespondToModel(format!( - "unsupported payload for shell_command handler: {tool_name}" - ))); - }; - - let Some(turn_environment) = step_context.environments.primary().cloned() else { - return Err(FunctionCallError::RespondToModel( - "shell is unavailable in this session".to_string(), - )); - }; - - let environment_cwd = turn_environment.cwd().to_abs_path().map_err(|err| { - FunctionCallError::RespondToModel(format!( - "shell_command cwd `{}` is not native to the Codex host: {err}", - turn_environment.cwd() - )) - })?; - let cwd = resolve_workdir_base_path(&arguments, &environment_cwd)?; - let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; - maybe_emit_implicit_skill_invocation( - session.as_ref(), - turn.as_ref(), - ¶ms.command, - &PathUri::from_abs_path(&cwd), - Some(&cwd), - LOCAL_ENVIRONMENT_ID, - ) - .await; - let prefix_rule = params.prefix_rule.clone(); - let exec_params = Self::to_exec_params( - ¶ms, - session.as_ref(), - turn.as_ref(), - &turn_environment, - cwd, - )?; - let shell_type = Some( - turn_environment - .shell - .as_ref() - .map_or_else(|| session.user_shell().shell_type, |shell| shell.shell_type), - ); - run_exec_like(RunExecLikeArgs { - tool_name, - exec_params, - cancellation_token, - hook_command: params.command, - shell_type, - additional_permissions: params.additional_permissions.clone(), - prefix_rule, - session, - step_context, - turn_environment, - tracker, - call_id, - shell_runtime_backend: self.shell_runtime_backend(), - }) - .await - .map(boxed_tool_output) - } -} - -impl CoreToolRuntime for ShellCommandHandler { - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!(payload, ToolPayload::Function { .. }) - } - - fn waits_for_runtime_cancellation(&self) -> bool { - true - } - - fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: serde_json::json!({ "command": command }), - }) - } - - fn with_updated_hook_input( - &self, - mut invocation: ToolInvocation, - updated_input: serde_json::Value, - ) -> Result { - let ToolPayload::Function { arguments } = invocation.payload else { - return Err(FunctionCallError::RespondToModel( - "hook input rewrite received unsupported shell_command payload".to_string(), - )); - }; - invocation.payload = ToolPayload::Function { - arguments: rewrite_function_string_argument( - &arguments, - "shell_command", - "command", - updated_hook_command(&updated_input)?, - )?, - }; - Ok(invocation) - } - - fn post_tool_use_payload( - &self, - invocation: &ToolInvocation, - result: &dyn crate::tools::context::ToolOutput, - ) -> Option { - let tool_response = - result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; - let command = shell_command_payload_command(&invocation.payload)?; - Some(PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id: invocation.call_id.clone(), - tool_input: serde_json::json!({ "command": command }), - tool_response, - }) - } -} diff --git a/codex-rs/core/src/tools/handlers/shell_spec.rs b/codex-rs/core/src/tools/handlers/shell_spec.rs index a07b741639..ff207e393e 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec.rs @@ -154,76 +154,6 @@ pub fn create_write_stdin_tool() -> ToolSpec { }) } -pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec { - let mut properties = BTreeMap::from([ - ( - "command".to_string(), - JsonSchema::string(Some( - "Shell script to run in the user's default shell.".to_string(), - )), - ), - ( - "workdir".to_string(), - JsonSchema::string(Some( - "Working directory for the command. Defaults to the turn cwd.".to_string(), - )), - ), - ( - "timeout_ms".to_string(), - JsonSchema::number(Some( - "Maximum command runtime. Defaults to 10000 ms.".to_string(), - )), - ), - ]); - if options.allow_login_shell { - properties.insert( - "login".to_string(), - JsonSchema::boolean(Some( - "True runs with login shell semantics; false disables them. Defaults to true." - .to_string(), - )), - ); - } - properties.extend(create_approval_parameters( - options.exec_permission_approvals_enabled, - )); - - let description = if cfg!(windows) { - format!( - r#"Runs a Powershell command (Windows) and returns its output. - -Examples of valid command strings: - -- ls -a (show hidden): "Get-ChildItem -Force" -- recursive find by name: "Get-ChildItem -Recurse -Filter *.py" -- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive" -- ps aux | grep python: "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}" -- setting an env var: "$env:FOO='bar'; echo $env:FOO" -- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -" - -{}"#, - windows_shell_guidance() - ) - } else { - r#"Runs a shell command and returns its output. -- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."# - .to_string() - }; - - ToolSpec::Function(ResponsesApiTool { - name: "shell_command".to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec!["command".to_string()]), - Some(false.into()), - ), - output_schema: None, - }) -} - pub fn create_request_permissions_tool(description: String) -> ToolSpec { let properties = BTreeMap::from([ ( diff --git a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs index 044a6112f7..9aeb67f536 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs @@ -201,77 +201,3 @@ fn request_permissions_tool_includes_full_permission_schema() { }) ); } - -#[test] -fn shell_command_tool_matches_expected_spec() { - let tool = create_shell_command_tool(CommandToolOptions { - allow_login_shell: true, - exec_permission_approvals_enabled: false, - }); - - let description = if cfg!(windows) { - r#"Runs a Powershell command (Windows) and returns its output. - -Examples of valid command strings: - -- ls -a (show hidden): "Get-ChildItem -Force" -- recursive find by name: "Get-ChildItem -Recurse -Filter *.py" -- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive" -- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }" -- setting an env var: "$env:FOO='bar'; echo $env:FOO" -- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -""# - .to_string() - + &windows_shell_guidance_description() - } else { - r#"Runs a shell command and returns its output. -- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."# - .to_string() - }; - - let mut properties = BTreeMap::from([ - ( - "command".to_string(), - JsonSchema::string(Some( - "Shell script to run in the user's default shell.".to_string(), - )), - ), - ( - "workdir".to_string(), - JsonSchema::string(Some( - "Working directory for the command. Defaults to the turn cwd.".to_string(), - )), - ), - ( - "timeout_ms".to_string(), - JsonSchema::number(Some( - "Maximum command runtime. Defaults to 10000 ms.".to_string(), - )), - ), - ( - "login".to_string(), - JsonSchema::boolean(Some( - "True runs with login shell semantics; false disables them. Defaults to true." - .to_string(), - )), - ), - ]); - properties.extend(create_approval_parameters( - /*exec_permission_approvals_enabled*/ false, - )); - - assert_eq!( - tool, - ToolSpec::Function(ResponsesApiTool { - name: "shell_command".to_string(), - description, - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec!["command".to_string()]), - Some(false.into()) - ), - output_schema: None, - }) - ); -} diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs deleted file mode 100644 index 4cf246a8b9..0000000000 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ /dev/null @@ -1,336 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; - -use codex_protocol::config_types::EnvironmentVariablePattern; -use codex_protocol::config_types::ShellEnvironmentPolicy; -use codex_protocol::config_types::ShellEnvironmentPolicyInherit; -use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ShellCommandToolCallParams; -use pretty_assertions::assert_eq; - -use crate::config::PermissionProfileSnapshot; -use crate::environment_selection::EnvironmentConfigOrigin; -use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR; -use crate::exec_env::create_env; -use crate::exec_env::inject_permission_profile_env; -use crate::exec_env::inject_session_id_env; -use crate::sandboxing::SandboxPermissions; -use crate::session::step_context::StepContext; -use crate::session::tests::make_session_and_context; -use crate::session::turn_context::TurnEnvironment; -use crate::shell::Shell; -use crate::shell::ShellType; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolCallSource; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::ShellCommandHandler; -use crate::tools::hook_names::HookToolName; -use crate::tools::registry::CoreToolRuntime; -use crate::turn_diff_tracker::TurnDiffTracker; -use codex_protocol::protocol::EnvironmentConfig; -use codex_protocol::protocol::EnvironmentConfigState; -use codex_protocol::protocol::TurnEnvironmentSelection; -use codex_utils_path_uri::PathUri; -use serde_json::json; -use tokio::sync::Mutex; - -#[tokio::test] -async fn shell_command_handler_to_exec_params_uses_selected_environment() { - let (session, mut turn_context) = make_session_and_context().await; - let permission_profile = turn_context.config.permissions.permission_profile().clone(); - let config = Arc::make_mut(&mut turn_context.config); - config.permissions.shell_environment_policy.r#set = HashMap::from([ - ("KEEP".to_string(), "from-thread".to_string()), - ("DROP".to_string(), "from-thread".to_string()), - ]); - config.permissions.shell_environment_policy.include_only = - vec![EnvironmentVariablePattern::new_case_insensitive("DROP")]; - config - .permissions - .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( - permission_profile.clone(), - ActivePermissionProfile::new("thread-profile"), - )) - .expect("set active permission profile"); - - let command = "echo hello".to_string(); - let workdir = Some("subdir".to_string()); - let login = None; - let timeout_ms = Some(1234); - let sandbox_permissions = SandboxPermissions::RequireEscalated; - let justification = Some("because tests".to_string()); - - let selected_shell = Shell { - shell_type: ShellType::Bash, - shell_path: PathBuf::from("/selected/bin/bash"), - }; - let expected_command = selected_shell.derive_exec_args(&command, /*use_login_shell*/ true); - let selected_cwd = turn_context.config.cwd.join("selected-environment"); - let expected_cwd = selected_cwd.join("subdir"); - let active_permission_profile = ActivePermissionProfile::new("selected-profile"); - let selected_shell_environment_policy = ShellEnvironmentPolicy { - inherit: ShellEnvironmentPolicyInherit::None, - include_only: vec![EnvironmentVariablePattern::new_case_insensitive("KEEP")], - r#set: turn_context - .config - .permissions - .shell_environment_policy - .r#set - .clone(), - ..Default::default() - }; - let selected_environment = TurnEnvironment::new( - TurnEnvironmentSelection { - environment_id: "selected-environment".to_string(), - cwd: PathUri::from_abs_path(&selected_cwd), - workspace_roots: Vec::new(), - config: EnvironmentConfigState::Ready(EnvironmentConfig { - allow_login_shell: true, - permission_profile: PermissionProfileSnapshot::active( - permission_profile, - active_permission_profile.clone(), - ), - shell_environment_policy: selected_shell_environment_policy.clone(), - exec_policy: None, - mcp_policy: None, - network_policy: None, - selected_capability_roots: Vec::new(), - }), - }, - EnvironmentConfigOrigin::Thread, - Arc::clone( - &turn_context - .environments - .primary() - .expect("primary environment") - .environment, - ), - Some(selected_shell), - ); - let mut expected_env = create_env(&selected_shell_environment_policy, Some(session.thread_id)); - inject_session_id_env(&mut expected_env, session.session_id()); - inject_permission_profile_env(&mut expected_env, Some(&active_permission_profile)); - - let params = ShellCommandToolCallParams { - command, - workdir, - login, - timeout_ms, - sandbox_permissions: Some(sandbox_permissions), - additional_permissions: None, - prefix_rule: None, - justification: justification.clone(), - }; - - let exec_params = ShellCommandHandler::to_exec_params( - ¶ms, - &session, - &turn_context, - &selected_environment, - expected_cwd.clone(), - ) - .expect("login shells should be allowed"); - - // ExecParams cannot derive Eq due to the CancellationToken field, so we manually compare the fields. - assert_eq!(exec_params.command, expected_command); - assert_eq!(exec_params.cwd, expected_cwd); - assert_eq!(exec_params.env, expected_env); - assert_eq!( - exec_params.env.get(CODEX_PERMISSION_PROFILE_ENV_VAR), - Some(&active_permission_profile.id) - ); - assert_eq!(exec_params.network, turn_context.network); - assert_eq!( - exec_params.network_environment_id.as_deref(), - Some("selected-environment") - ); - assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms); - assert_eq!(exec_params.sandbox_permissions, sandbox_permissions); - assert_eq!(exec_params.justification, justification); - assert_eq!(exec_params.arg0, None); -} - -#[test] -fn shell_command_handler_respects_explicit_login_flag() { - let shell = Shell { - shell_type: ShellType::Bash, - shell_path: PathBuf::from("/bin/bash"), - }; - - let login_command = ShellCommandHandler::base_command( - &shell, - "echo login shell", - /*use_login_shell*/ true, - ); - assert_eq!( - login_command, - shell.derive_exec_args("echo login shell", /*use_login_shell*/ true) - ); - - let non_login_command = ShellCommandHandler::base_command( - &shell, - "echo non login shell", - /*use_login_shell*/ false, - ); - assert_eq!( - non_login_command, - shell.derive_exec_args("echo non login shell", /*use_login_shell*/ false) - ); -} - -#[tokio::test] -async fn shell_command_handler_defaults_to_non_login_when_disallowed() { - let (session, turn_context) = make_session_and_context().await; - let mut turn_environment = turn_context - .environments - .primary() - .expect("primary environment") - .clone(); - turn_environment.config_mut().allow_login_shell = false; - let cwd = turn_environment - .cwd() - .to_abs_path() - .expect("native environment cwd"); - let params = ShellCommandToolCallParams { - command: "echo hello".to_string(), - workdir: None, - login: None, - timeout_ms: None, - sandbox_permissions: None, - additional_permissions: None, - prefix_rule: None, - justification: None, - }; - - let exec_params = ShellCommandHandler::to_exec_params( - ¶ms, - &session, - &turn_context, - &turn_environment, - cwd, - ) - .expect("non-login shells should still be allowed"); - - assert_eq!( - exec_params.command, - session - .user_shell() - .derive_exec_args("echo hello", /*use_login_shell*/ false) - ); -} - -#[tokio::test] -async fn shell_command_handler_rejects_justification_without_sandbox_permissions() { - let (session, turn_context) = make_session_and_context().await; - let turn_environment = turn_context - .environments - .primary() - .expect("primary environment"); - let cwd = turn_environment - .cwd() - .to_abs_path() - .expect("native environment cwd"); - let params = ShellCommandToolCallParams { - command: "echo hello".to_string(), - workdir: None, - login: None, - timeout_ms: None, - sandbox_permissions: None, - additional_permissions: None, - prefix_rule: None, - justification: Some("Allow this command".to_string()), - }; - - let err = ShellCommandHandler::to_exec_params( - ¶ms, - &session, - &turn_context, - turn_environment, - cwd, - ) - .expect_err("justification without sandbox permissions should be rejected"); - - assert!( - err.to_string() - .contains("`justification` requires an explicit `sandbox_permissions`"), - "unexpected error: {err}" - ); -} - -#[test] -fn shell_command_handler_rejects_login_when_disallowed() { - let err = - ShellCommandHandler::resolve_use_login_shell(Some(true), /*allow_login_shell*/ false) - .expect_err("explicit login should be rejected"); - - assert!( - err.to_string() - .contains("login shell is disabled by config"), - "unexpected error: {err}" - ); -} - -#[tokio::test] -async fn shell_command_pre_tool_use_payload_uses_raw_command() { - let payload = ToolPayload::Function { - arguments: json!({ "command": "printf shell command" }).to_string(), - }; - let (session, turn) = make_session_and_context().await; - let turn = Arc::new(turn); - let handler = ShellCommandHandler::from(codex_tools::ShellCommandBackendConfig::Classic); - - assert_eq!( - handler.pre_tool_use_payload(&ToolInvocation { - session: session.into(), - step_context: StepContext::for_test(Arc::clone(&turn)), - turn, - cancellation_token: tokio_util::sync::CancellationToken::new(), - tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), - call_id: "call-42".to_string(), - tool_name: codex_tools::ToolName::plain("shell_command"), - source: crate::tools::context::ToolCallSource::Direct, - payload, - }), - Some(crate::tools::registry::PreToolUsePayload { - tool_name: HookToolName::bash(), - tool_input: json!({ "command": "printf shell command" }), - }) - ); -} - -#[tokio::test] -async fn build_post_tool_use_payload_uses_tool_output_wire_value() { - let payload = ToolPayload::Function { - arguments: json!({ "command": "printf shell command" }).to_string(), - }; - let output = FunctionToolOutput { - body: vec![], - success: Some(true), - post_tool_use_response: Some(json!("shell output")), - }; - let handler = ShellCommandHandler::from(codex_tools::ShellCommandBackendConfig::Classic); - let (session, turn) = make_session_and_context().await; - let turn = Arc::new(turn); - let invocation = ToolInvocation { - session: session.into(), - step_context: StepContext::for_test(Arc::clone(&turn)), - turn, - cancellation_token: tokio_util::sync::CancellationToken::new(), - tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), - call_id: "call-42".to_string(), - tool_name: codex_tools::ToolName::plain("shell_command"), - source: ToolCallSource::Direct, - payload, - }; - assert_eq!( - handler.post_tool_use_payload(&invocation, &output), - Some(crate::tools::registry::PostToolUsePayload { - tool_name: HookToolName::bash(), - tool_use_id: "call-42".to_string(), - tool_input: json!({ "command": "printf shell command" }), - tool_response: json!("shell output"), - }) - ); -} diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index d32e849bfa..203dd09efb 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -45,16 +45,9 @@ use uuid::Uuid; const ABANDONED_NETWORK_APPROVAL_MESSAGE: &str = "network approval was cancelled before a decision was returned"; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum NetworkApprovalMode { - Immediate, - Deferred, -} - #[derive(Clone, Debug)] pub(crate) struct NetworkApprovalSpec { pub network: Option, - pub mode: NetworkApprovalMode, pub trigger: GuardianNetworkAccessTrigger, pub command: String, pub environment_id: String, @@ -97,16 +90,11 @@ impl DeferredNetworkApproval { #[derive(Debug)] pub(crate) struct ActiveNetworkApproval { registration_id: Option, - mode: NetworkApprovalMode, cancellation_token: CancellationToken, execution_proxy: NetworkProxy, } impl ActiveNetworkApproval { - pub(crate) fn mode(&self) -> NetworkApprovalMode { - self.mode - } - pub(crate) fn cancellation_token(&self) -> CancellationToken { self.cancellation_token.clone() } @@ -118,21 +106,15 @@ impl ActiveNetworkApproval { pub(crate) fn into_deferred(self) -> Option { let ActiveNetworkApproval { registration_id, - mode, cancellation_token, execution_proxy, } = self; - match (mode, registration_id) { - (NetworkApprovalMode::Deferred, Some(registration_id)) => { - Some(DeferredNetworkApproval { - registration_id, - cancellation_token, - finish_outcome: Arc::new(OnceCell::new()), - _execution_proxy: Some(execution_proxy), - }) - } - _ => None, - } + registration_id.map(|registration_id| DeferredNetworkApproval { + registration_id, + cancellation_token, + finish_outcome: Arc::new(OnceCell::new()), + _execution_proxy: Some(execution_proxy), + }) } } @@ -576,18 +558,6 @@ impl NetworkApprovalService { self.remove_call(registration_id).await } - async fn finish_call( - &self, - registration_id: &str, - cancellation_token: &CancellationToken, - ) -> Result<(), ToolError> { - let outcome = self - .finish_call_outcome(registration_id) - .await - .or_else(|| abandoned_network_approval_outcome(cancellation_token)); - network_approval_outcome_to_result(outcome) - } - pub(crate) async fn record_blocked_request(&self, blocked: BlockedRequest) { let Some(message) = denied_network_policy_message(&blocked) else { return; @@ -1055,7 +1025,6 @@ pub(crate) async fn begin_network_approval( ) -> Result, ToolError> { let NetworkApprovalSpec { network, - mode, trigger, command, environment_id, @@ -1097,27 +1066,11 @@ pub(crate) async fn begin_network_approval( Ok(Some(ActiveNetworkApproval { registration_id: Some(registration_id), - mode, cancellation_token, execution_proxy, })) } -pub(crate) async fn finish_immediate_network_approval( - session: &Session, - active: ActiveNetworkApproval, -) -> Result<(), ToolError> { - let Some(registration_id) = active.registration_id.as_deref() else { - return Ok(()); - }; - - session - .services - .network_approval - .finish_call(registration_id, &active.cancellation_token) - .await -} - pub(crate) async fn finish_deferred_network_approval( session: &Session, deferred: Option, diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index f54f4d268c..dceaac5b0a 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -480,7 +480,7 @@ async fn register_call_with_default_shell_trigger( turn_id: "turn-1".to_string(), trigger: GuardianNetworkAccessTrigger { call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), + tool_name: "exec_command".to_string(), command: vec!["curl".to_string(), "https://example.com".to_string()], cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()), sandbox_permissions: SandboxPermissions::UseDefault, @@ -502,7 +502,7 @@ async fn active_call_preserves_triggering_command_context() { let service = NetworkApprovalService::default(); let expected = GuardianNetworkAccessTrigger { call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), + tool_name: "exec_command".to_string(), command: vec!["curl".to_string(), "https://example.com".to_string()], cwd: PathUri::parse("file:///C:/repo").expect("valid Windows path URI"), sandbox_permissions: SandboxPermissions::UseDefault, @@ -669,42 +669,6 @@ fn approval_denial_messages_are_bounded_for_model_context() { assert!(message.contains("tokens truncated")); } -#[tokio::test] -async fn finish_call_returns_denial_and_unregisters_active_call() { - let service = NetworkApprovalService::default(); - let cancellation_token = - register_call_with_default_shell_trigger(&service, "registration-1").await; - - service.record_call_outcome("registration-1", "network denied".to_string()); - - let err = service - .finish_call("registration-1", &cancellation_token) - .await - .expect_err("denial should be returned"); - - assert!(matches!(err, ToolError::Rejected(message) if message == "network denied")); - assert!(service.resolve_single_active_call().await.is_none()); - assert_eq!(service.take_call_outcome("registration-1").await, None); -} - -#[tokio::test] -async fn finish_call_reports_abandoned_network_approval() { - let service = NetworkApprovalService::default(); - let cancellation_token = - register_call_with_default_shell_trigger(&service, "registration-1").await; - cancellation_token.cancel(); - - let err = service - .finish_call("registration-1", &cancellation_token) - .await - .expect_err("abandoned approval should be returned"); - - assert!(matches!( - err, - ToolError::Rejected(message) if message == ABANDONED_NETWORK_APPROVAL_MESSAGE - )); -} - #[tokio::test] async fn deferred_finish_reuses_denial_result_after_first_consumer() { let service = NetworkApprovalService::default(); diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index ad3ab60a9b..0a57d7a514 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -12,10 +12,8 @@ use crate::tools::approvals::ApprovalContext; use crate::tools::flat_tool_name; use crate::tools::network_approval::ActiveNetworkApproval; use crate::tools::network_approval::DeferredNetworkApproval; -use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::begin_network_approval; use crate::tools::network_approval::finish_deferred_network_approval; -use crate::tools::network_approval::finish_immediate_network_approval; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; @@ -108,28 +106,16 @@ impl ToolOrchestrator { return (run_result, None); }; - match network_approval.mode() { - NetworkApprovalMode::Immediate => { - let finalize_result = - finish_immediate_network_approval(&tool_ctx.session, network_approval).await; - if let Err(err) = finalize_result { - return (Err(err), None); - } - (run_result, None) - } - NetworkApprovalMode::Deferred => { - let deferred = network_approval.into_deferred(); - if run_result.is_err() { - let finalize_result = - finish_deferred_network_approval(&tool_ctx.session, deferred).await; - if let Err(err) = finalize_result { - return (Err(err), None); - } - return (run_result, None); - } - (run_result, deferred) + let deferred = network_approval.into_deferred(); + if run_result.is_err() { + let finalize_result = + finish_deferred_network_approval(&tool_ctx.session, deferred).await; + if let Err(err) = finalize_result { + return (Err(err), None); } + return (run_result, None); } + (run_result, deferred) } pub async fn run( diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 4f1d0b75a7..b75e8943e5 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -269,7 +269,7 @@ impl ToolCallRuntime { if call.tool_name.is_default_namespace() && matches!( call.tool_name.name.as_str(), - "shell_command" | "unified_exec" + "exec_command" | "unified_exec" ) { format!("Wall time: {secs:.1} seconds\naborted by user") diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index e7c416d077..48322c3b0b 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -350,7 +350,9 @@ impl ToolRegistry { exposure: ToolExposure, ) -> bool { let tool_name = runtime.tool_name().with_default_namespace(); - if tool_name.is_default_namespace() && tool_name.name == "shell_command" { + if tool_name.is_default_namespace() + && matches!(tool_name.name.as_str(), "exec_command" | "shell_command") + { tracing::warn!(tool_name = %tool_name, "skipping external tool with reserved name"); if self.tools.contains_key(&tool_name) { self.record_collision(tool_name); diff --git a/codex-rs/core/src/tools/registry_tests.rs b/codex-rs/core/src/tools/registry_tests.rs index 0555a6e3b3..6cc67a8053 100644 --- a/codex-rs/core/src/tools/registry_tests.rs +++ b/codex-rs/core/src/tools/registry_tests.rs @@ -296,39 +296,41 @@ fn registry_preserves_external_winners_and_trusted_synthetic_order() { } #[test] -fn reserved_shell_command_rejects_external_runtimes_without_a_builtin() { +fn reserved_command_tools_reject_external_runtimes_without_a_builtin() { let handler = |tool_name| Arc::new(TestHandler { tool_name }) as Arc; - let shell_command_name = codex_tools::ToolName::plain("shell_command"); - let namespaced_shell_command_name = - codex_tools::ToolName::namespaced("client", "shell_command"); let mut registry = ToolRegistry::default(); - assert!(!registry.register_external(handler(shell_command_name.clone()))); - assert!(!registry.register_external_with_exposure( - handler(shell_command_name.clone()), - ToolExposure::Direct, - )); - assert!( - !registry.register_external(handler(codex_tools::ToolName::namespaced( - DEFAULT_FUNCTION_NAMESPACE, - "shell_command", - ))) - ); - assert!(registry.tool(&shell_command_name).is_none()); - assert_eq!(registry.first_collision(), None); + for reserved_name in ["exec_command", "shell_command"] { + let tool_name = codex_tools::ToolName::plain(reserved_name); + let namespaced_tool_name = codex_tools::ToolName::namespaced("client", reserved_name); - let namespaced_handler = handler(namespaced_shell_command_name.clone()); - assert!(registry.register_external(Arc::clone(&namespaced_handler))); - assert!( - registry - .tool(&namespaced_shell_command_name) - .is_some_and(|runtime| Arc::ptr_eq(&runtime, &namespaced_handler)) - ); + assert!(!registry.register_external(handler(tool_name.clone()))); + assert!( + !registry + .register_external_with_exposure(handler(tool_name.clone()), ToolExposure::Direct) + ); + assert!( + !registry.register_external(handler(codex_tools::ToolName::namespaced( + DEFAULT_FUNCTION_NAMESPACE, + reserved_name, + ))) + ); + assert!(registry.tool(&tool_name).is_none()); + assert_eq!(registry.first_collision(), None); + + let namespaced_handler = handler(namespaced_tool_name.clone()); + assert!(registry.register_external(Arc::clone(&namespaced_handler))); + assert!( + registry + .tool(&namespaced_tool_name) + .is_some_and(|runtime| Arc::ptr_eq(&runtime, &namespaced_handler)) + ); + } } #[test] -fn registry_records_reserved_shell_command_when_a_matching_tool_exists() { - let tool_name = codex_tools::ToolName::plain("shell_command"); +fn registry_records_reserved_exec_command_when_a_matching_tool_exists() { + let tool_name = codex_tools::ToolName::plain("exec_command"); let trusted = Arc::new(TestHandler { tool_name: tool_name.clone(), }) as Arc; diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index f763ca266d..9d80d39a0f 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -167,7 +167,7 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow &turn.dynamic_tools, ); - let parallel_tool_name = ["exec_command", "shell_command"] + let parallel_tool_name = ["exec_command"] .into_iter() .find(|name| { router.tool_supports_parallel(&ToolCall { diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 34ed397bf6..368bc3c418 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -10,7 +10,6 @@ use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::ShellType; -use crate::tools::sandboxing::ToolError; use codex_apply_patch::CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR; use codex_core_plugins::PLUGIN_METRICS_OUTPUT_ENV_VAR; #[cfg(unix)] @@ -25,11 +24,8 @@ use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; pub(crate) use codex_network_proxy::is_managed_proxy_env_var; pub(crate) use codex_network_proxy::strip_managed_proxy_env; use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::shell_environment::is_non_inheritable_env_var; -use codex_sandboxing::SandboxCommand; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_path_uri::PathUri; use std::collections::HashMap; #[cfg(unix)] use std::path::Path; @@ -38,28 +34,6 @@ pub(crate) mod apply_patch; pub(crate) mod shell; pub(crate) mod unified_exec; -/// Shared helper to construct sandbox transform inputs from a tokenized command line and native -/// working directory. Validates that at least a program is present. -pub(crate) fn build_sandbox_command( - command: &[String], - cwd: &AbsolutePathBuf, - env: &HashMap, - additional_permissions: Option, -) -> Result { - let (program, args) = command - .split_first() - .ok_or_else(|| ToolError::Rejected("command args are empty".to_string()))?; - let cwd = PathUri::from_abs_path(cwd); - Ok(SandboxCommand { - program: program.clone().into(), - args: args.to_vec(), - cwd, - env: env.clone(), - managed_network: None, - additional_permissions, - }) -} - pub(crate) fn exec_env_for_sandbox_permissions( env: &HashMap, sandbox_permissions: SandboxPermissions, @@ -156,17 +130,6 @@ pub(crate) fn apply_package_path_prepend( runtime_path_prepends.prepend(env, path_dir.as_path()); } -#[cfg(unix)] -pub(crate) fn prepend_zsh_fork_bin_to_path( - env: &mut HashMap, - shell_zsh_path: &Path, -) -> Option { - let zsh_bin_dir = shell_zsh_path - .parent() - .map(|path| path.to_string_lossy().to_string())?; - prepend_path_entry(env, &zsh_bin_dir) -} - #[cfg(unix)] pub(crate) fn apply_zsh_fork_path_prepend( env: &mut HashMap, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index b4e4c9d157..b99cd4bedc 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -88,14 +88,14 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: let mut env = HashMap::from([("CUSTOM_ENV".to_string(), "kept".to_string())]); proxy.apply_to_env(&mut env); - let command = vec!["/bin/echo".to_string(), "ok".to_string()]; - let command = build_sandbox_command( - &command, - &command_cwd, - &exec_env_for_sandbox_permissions(&env, SandboxPermissions::RequireEscalated), - /*additional_permissions*/ None, - ) - .expect("build sandbox command"); + let command = codex_sandboxing::SandboxCommand { + program: "/bin/echo".into(), + args: vec!["ok".to_string()], + cwd: PathUri::from_abs_path(&command_cwd), + env: exec_env_for_sandbox_permissions(&env, SandboxPermissions::RequireEscalated), + managed_network: None, + additional_permissions: None, + }; assert_eq!(command.cwd, PathUri::from_abs_path(&command_cwd)); let sandbox_policy_cwd = PathUri::from_abs_path(&native_sandbox_policy_cwd); let options = ExecOptions { @@ -236,24 +236,6 @@ fn runtime_path_prepends_ignores_empty_path_entry() { ); } -#[cfg(unix)] -#[test] -fn prepend_zsh_fork_bin_to_path_ignores_empty_parent() { - let mut env = HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]); - - let result = prepend_zsh_fork_bin_to_path(&mut env, PathBuf::from("zsh").as_path()); - - assert_eq!( - result, None, - "zsh fork helper should not report a PATH update for an empty parent" - ); - assert_eq!( - env.get("PATH").map(String::as_str), - Some("/usr/bin:/bin"), - "zsh fork helper should leave PATH unchanged when the parent is empty" - ); -} - #[cfg(unix)] #[test] fn apply_zsh_fork_path_prepend_uses_shell_parent() { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs deleted file mode 100644 index 58fc9c4021..0000000000 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ /dev/null @@ -1,331 +0,0 @@ -/* -Runtime: shell - -Executes shell requests under the orchestrator: asks for approval when needed, -builds sandbox transform inputs, and runs them under the current SandboxAttempt. -*/ -#[cfg(unix)] -pub(crate) mod unix_escalation; -pub(crate) mod zsh_fork_backend; - -use crate::exec::ExecCapturePolicy; -use crate::guardian::GuardianNetworkAccessTrigger; -use crate::plugins::metrics::finish_and_track_measurements; -use crate::plugins::metrics::sidecar_for_command; -use crate::sandboxing::ExecOptions; -use crate::sandboxing::SandboxPermissions; -use crate::sandboxing::execute_env; -use crate::session::turn_context::TurnEnvironment; -use crate::shell::ShellType; -use crate::tools::flat_tool_name; -use crate::tools::network_approval::NetworkApprovalMode; -use crate::tools::network_approval::NetworkApprovalSpec; -use crate::tools::runtimes::RuntimePathPrepends; -#[cfg(unix)] -use crate::tools::runtimes::apply_zsh_fork_path_prepend; -use crate::tools::runtimes::build_sandbox_command; -use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox; -use crate::tools::runtimes::exec_env_for_sandbox_permissions; -use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; -use crate::tools::sandboxing::Approvable; -use crate::tools::sandboxing::ApprovalAction; -use crate::tools::sandboxing::ExecApprovalRequirement; -use crate::tools::sandboxing::SandboxAttempt; -use crate::tools::sandboxing::Sandboxable; -use crate::tools::sandboxing::ToolCtx; -use crate::tools::sandboxing::ToolError; -use crate::tools::sandboxing::ToolRuntime; -use crate::tools::sandboxing::managed_network_for_sandbox_permissions; -use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads; -use codex_core_plugins::PluginMetricsSidecar; -use codex_network_proxy::NetworkProxy; -use codex_protocol::exec_output::ExecToolCallOutput; -use codex_protocol::models::AdditionalPermissionProfile; -use codex_sandboxing::SandboxablePreference; -use codex_sandboxing::policy_transforms::merge_permission_profiles; -use codex_shell_command::powershell::prefix_powershell_script_with_utf8; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_path_uri::PathUri; -use std::collections::HashMap; -use tokio_util::sync::CancellationToken; - -#[derive(Clone, Debug)] -pub struct ShellRequest { - pub command: Vec, - pub turn_environment: TurnEnvironment, - pub shell_type: Option, - pub hook_command: String, - pub cwd: AbsolutePathBuf, - pub timeout_ms: Option, - pub cancellation_token: CancellationToken, - pub env: HashMap, - pub explicit_env_overrides: HashMap, - pub network: Option, - pub sandbox_permissions: SandboxPermissions, - pub additional_permissions: Option, - #[cfg(unix)] - pub additional_permissions_preapproved: bool, - pub justification: Option, - pub exec_approval_requirement: ExecApprovalRequirement, -} - -/// Selects `ShellRuntime` behavior for different callers. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ShellRuntimeBackend { - /// Legacy backend for the `shell_command` tool. - /// - /// Keeps `shell_command` on the standard shell runtime flow without the - /// zsh-fork shell-escalation adapter. - ShellCommandClassic, - /// zsh-fork backend for the `shell_command` tool. - /// - /// On Unix, attempts to run via the zsh-fork + `codex-shell-escalation` - /// adapter, with fallback to the standard shell runtime flow if - /// prerequisites are not met. - ShellCommandZshFork, -} - -pub struct ShellRuntime { - backend: ShellRuntimeBackend, -} - -#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] -pub(crate) struct ApprovalKey { - pub(crate) environment_id: String, - pub(crate) command: Vec, - pub(crate) cwd: PathUri, - pub(crate) sandbox_permissions: SandboxPermissions, - pub(crate) additional_permissions: Option, -} - -impl ShellRuntime { - pub(crate) fn for_shell_command(backend: ShellRuntimeBackend) -> Self { - Self { backend } - } - - fn stdout_stream(ctx: &ToolCtx) -> Option { - Some(crate::exec::StdoutStream { - sub_id: ctx.step_context.turn.sub_id.clone(), - call_id: ctx.call_id.clone(), - tx_event: ctx.session.get_tx_event(), - }) - } -} - -impl Sandboxable for ShellRuntime { - fn sandbox_preference(&self) -> SandboxablePreference { - SandboxablePreference::Auto - } - fn escalate_on_failure(&self) -> bool { - true - } -} - -impl Approvable for ShellRuntime { - fn approval_action( - &self, - req: &ShellRequest, - call_id: &str, - ) -> std::io::Result { - Ok(ApprovalAction::Shell { - id: call_id.to_string(), - environment_id: req.turn_environment.selection.environment_id.clone(), - command: req.command.clone(), - hook_command: req.hook_command.clone(), - cwd: PathUri::from_abs_path(&req.cwd), - sandbox_permissions: req.sandbox_permissions, - additional_permissions: req.additional_permissions.clone(), - justification: req.justification.clone(), - proposed_execpolicy_amendment: req - .exec_approval_requirement - .proposed_execpolicy_amendment() - .cloned(), - }) - } - - fn exec_approval_requirement(&self, req: &ShellRequest) -> Option { - Some(req.exec_approval_requirement.clone()) - } - - fn sandbox_permissions(&self, req: &ShellRequest) -> SandboxPermissions { - req.sandbox_permissions - } -} - -impl ToolRuntime for ShellRuntime { - fn turn_environment<'a>(&self, req: &'a ShellRequest) -> &'a TurnEnvironment { - &req.turn_environment - } - - fn network_approval_spec( - &self, - req: &ShellRequest, - ctx: &ToolCtx, - ) -> Option { - let file_system_sandbox_policy = req - .turn_environment - .permission_profile() - .file_system_sandbox_policy(); - let sandbox_permissions = sandbox_permissions_preserving_denied_reads( - req.sandbox_permissions, - &file_system_sandbox_policy, - ); - let network = - managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)?; - Some(NetworkApprovalSpec { - network: Some(network.clone()), - mode: NetworkApprovalMode::Immediate, - trigger: GuardianNetworkAccessTrigger { - call_id: ctx.call_id.clone(), - tool_name: flat_tool_name(&ctx.tool_name).into_owned(), - command: req.command.clone(), - cwd: PathUri::from_abs_path(&req.cwd), - sandbox_permissions: req.sandbox_permissions, - additional_permissions: req.additional_permissions.clone(), - justification: req.justification.clone(), - tty: None, - }, - command: req.hook_command.clone(), - environment_id: req.turn_environment.selection.environment_id.clone(), - permission_profile: req.turn_environment.permission_profile().clone(), - }) - } - - async fn run( - &mut self, - req: &ShellRequest, - attempt: &SandboxAttempt<'_>, - ctx: &ToolCtx, - ) -> Result { - let session_shell = ctx.session.user_shell(); - let shell = req - .turn_environment - .shell - .as_ref() - .unwrap_or(session_shell.as_ref()); - let shell_snapshot_location = req.turn_environment.shell_snapshot(&req.cwd); - let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions(); - let sandbox_permissions = sandbox_permissions_preserving_denied_reads( - req.sandbox_permissions, - &file_system_sandbox_policy, - ); - let managed_network = - managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions); - let mut env = exec_env_for_sandbox_permissions(&req.env, sandbox_permissions); - let explicit_env_overrides = req.explicit_env_overrides.clone(); - let cwd = PathUri::from_abs_path(&req.cwd); - let metrics_sidecar = sidecar_for_command( - ctx, - &req.command, - &cwd, - req.turn_environment.environment.as_ref(), - ) - .await; - if let Some(sidecar) = metrics_sidecar.as_ref() { - sidecar.install_output_env(&mut env); - } - #[cfg(unix)] - let (env, runtime_path_prepends) = { - let mut env = env; - let mut runtime_path_prepends = RuntimePathPrepends::default(); - crate::tools::runtimes::apply_package_path_prepend( - &mut env, - &mut runtime_path_prepends, - ); - if self.backend == ShellRuntimeBackend::ShellCommandZshFork - && let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_deref() - { - apply_zsh_fork_path_prepend(&mut env, &mut runtime_path_prepends, shell_zsh_path); - } - (env, runtime_path_prepends) - }; - #[cfg(not(unix))] - let runtime_path_prepends = RuntimePathPrepends::default(); - let command = maybe_wrap_shell_lc_with_snapshot( - &req.command, - shell, - shell_snapshot_location.as_ref(), - &explicit_env_overrides, - &env, - &runtime_path_prepends, - ); - let command = disable_powershell_profile_for_elevated_windows_sandbox( - &command, - req.shell_type.as_ref(), - attempt.sandbox_requested, - attempt.windows_sandbox_level, - ); - let command = if matches!(shell.shell_type, ShellType::PowerShell) { - prefix_powershell_script_with_utf8(&command) - } else { - command - }; - - let zsh_fork_output = if self.backend == ShellRuntimeBackend::ShellCommandZshFork { - match zsh_fork_backend::maybe_run_shell_command( - req, - attempt, - ctx, - &command, - metrics_sidecar.as_ref(), - ) - .await? - { - Some(out) => Some(out), - None => { - tracing::warn!( - "ZshFork backend specified, but conditions for using it were not met, falling back to normal execution", - ); - None - } - } - } else { - None - }; - let out = if let Some(out) = zsh_fork_output { - out - } else { - let sidecar_permissions = metrics_sidecar - .as_ref() - .map(PluginMetricsSidecar::additional_permissions); - let additional_permissions = merge_permission_profiles( - req.additional_permissions.as_ref(), - sidecar_permissions.as_ref(), - ); - let command = build_sandbox_command(&command, &req.cwd, &env, additional_permissions)?; - let mut expiration: crate::exec::ExecExpiration = req.timeout_ms.into(); - expiration = expiration.with_cancellation(req.cancellation_token.clone()); - if let Some(cancellation) = attempt.network_denial_cancellation_token.clone() { - expiration = expiration.with_cancellation(cancellation); - } - let options = ExecOptions { - expiration, - capture_policy: ExecCapturePolicy::ShellTool, - }; - let env = attempt - .env_for( - command, - options, - managed_network, - Some(&req.turn_environment.selection.environment_id), - ) - .map_err(ToolError::Codex)?; - execute_env(env, Self::stdout_stream(ctx)) - .await - .map_err(ToolError::Codex)? - }; - finish_and_track_measurements( - metrics_sidecar, - out.exit_code, - &ctx.session, - &ctx.step_context.turn, - &ctx.call_id, - ) - .await; - Ok(out) - } -} - -#[cfg(test)] -#[path = "shell_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/runtimes/shell/mod.rs b/codex-rs/core/src/tools/runtimes/shell/mod.rs new file mode 100644 index 0000000000..9c4f6bc4a4 --- /dev/null +++ b/codex-rs/core/src/tools/runtimes/shell/mod.rs @@ -0,0 +1,3 @@ +#[cfg(unix)] +pub(crate) mod unix_escalation; +pub(crate) mod zsh_fork_backend; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 41cf4020a4..6bf5f3a533 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -1,36 +1,23 @@ -use super::ShellRequest; use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; -use crate::exec::cancel_when_either; -use crate::exec::is_likely_sandbox_denied; use crate::guardian::GuardianReviewContext; use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxPermissions; -use crate::shell::ShellType; use crate::tools::approvals::ApprovalAction; use crate::tools::approvals::ApprovalContext; -use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::exec_env_for_sandbox_permissions; -use crate::tools::runtimes::prepend_zsh_fork_bin_to_path; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; -use crate::tools::sandboxing::managed_network_for_sandbox_permissions; -use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads; use crate::tools::sandboxing::unsandboxed_execution_allowed; -use codex_core_plugins::PluginMetricsSidecar; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::MatchOptions; use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; -use codex_features::Feature; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; -use codex_protocol::error::SandboxErr; -use codex_protocol::exec_output::ExecToolCallOutput; -use codex_protocol::exec_output::StreamOutput; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -42,8 +29,6 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; -use codex_sandboxing::policy_transforms::merge_permission_profiles; -use codex_sandboxing::record_filesystem_sandbox_violation; use codex_shell_command::bash::parse_shell_lc_plain_commands; use codex_shell_escalation::EscalateServer; use codex_shell_escalation::EscalationDecision; @@ -52,7 +37,6 @@ use codex_shell_escalation::EscalationPermissions; use codex_shell_escalation::EscalationPolicy; use codex_shell_escalation::EscalationPolicyFuture; use codex_shell_escalation::EscalationSession; -use codex_shell_escalation::ExecParams; use codex_shell_escalation::ExecResult; use codex_shell_escalation::PreparedExec; use codex_shell_escalation::ResolvedPermissionProfile; @@ -66,7 +50,6 @@ use std::collections::HashMap; use std::io; use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; use tracing::error; @@ -99,178 +82,6 @@ fn approval_sandbox_permissions( } } -pub(super) async fn try_run_zsh_fork( - req: &ShellRequest, - attempt: &SandboxAttempt<'_>, - ctx: &ToolCtx, - command: &[String], - metrics_sidecar: Option<&PluginMetricsSidecar>, -) -> Result, ToolError> { - let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_ref() else { - tracing::warn!("ZshFork backend specified, but shell_zsh_path is not configured."); - return Ok(None); - }; - if !ctx.session.features().enabled(Feature::ShellZshFork) { - tracing::warn!("ZshFork backend specified, but ShellZshFork feature is not enabled."); - return Ok(None); - } - if !matches!(ctx.session.user_shell().shell_type, ShellType::Zsh) { - tracing::warn!("ZshFork backend specified, but user shell is not Zsh."); - return Ok(None); - } - - let (attempt_file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions(); - let sandbox_permissions = sandbox_permissions_preserving_denied_reads( - req.sandbox_permissions, - &attempt_file_system_sandbox_policy, - ); - let req = &ShellRequest { - sandbox_permissions, - ..req.clone() - }; - let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions); - if let Some(sidecar) = metrics_sidecar { - sidecar.install_output_env(&mut env); - } - prepend_zsh_fork_bin_to_path(&mut env, shell_zsh_path); - let sidecar_permissions = metrics_sidecar.map(PluginMetricsSidecar::additional_permissions); - let additional_permissions = merge_permission_profiles( - req.additional_permissions.as_ref(), - sidecar_permissions.as_ref(), - ); - let command = build_sandbox_command(command, &req.cwd, &env, additional_permissions)?; - let options = ExecOptions { - expiration: req.timeout_ms.into(), - capture_policy: ExecCapturePolicy::ShellTool, - }; - let sandbox_exec_request = attempt - .env_for( - command, - options, - managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions), - Some(&req.turn_environment.selection.environment_id), - ) - .map_err(ToolError::Codex)?; - let crate::sandboxing::ExecRequest { - command, - cwd: sandbox_cwd, - env: sandbox_env, - exec_server_env_config: _, - network: sandbox_network, - network_environment_id, - expiration: _sandbox_expiration, - capture_policy: _capture_policy, - sandbox, - windows_sandbox_policy_cwd: sandbox_policy_cwd, - windows_sandbox_workspace_roots, - windows_sandbox_level, - windows_sandbox_private_desktop: _windows_sandbox_private_desktop, - permission_profile, - windows_sandbox_filesystem_overrides: _windows_sandbox_filesystem_overrides, - arg0, - exec_server_sandbox: _, - exec_server_enforce_managed_network: _, - exec_server_managed_network: _, - exec_server_network_proxy: _, - } = sandbox_exec_request; - let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; - let effective_timeout = Duration::from_millis( - req.timeout_ms - .unwrap_or(crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS), - ); - let exec_policy = Arc::new(RwLock::new( - ctx.session - .services - .exec_policy - .current_for_environment( - req.turn_environment.config().exec_policy.as_ref(), - ctx.step_context.turn.allow_prefix_rules(), - ) - .as_ref() - .clone(), - )); - // TODO(anp): Keep PathUri through the shell escalation boundary. - let sandbox_cwd = sandbox_cwd - .to_abs_path() - .map_err(|err| ToolError::Rejected(err.to_string()))?; - // TODO(anp): Keep PathUri through the shell sandbox policy boundary. - let sandbox_policy_cwd = sandbox_policy_cwd - .to_abs_path() - .map_err(|err| ToolError::Rejected(err.to_string()))?; - let command_executor = CoreShellCommandExecutor { - command, - cwd: sandbox_cwd, - permission_profile, - sandbox, - env: sandbox_env, - network: sandbox_network, - network_environment_id, - windows_sandbox_level, - arg0, - sandbox_policy_cwd, - windows_sandbox_workspace_roots, - codex_linux_sandbox_exe: ctx.step_context.turn.config.codex_linux_sandbox_exe.clone(), - use_legacy_landlock: ctx.step_context.turn.config.features.use_legacy_landlock(), - }; - let main_execve_wrapper_exe = ctx - .session - .services - .main_execve_wrapper_exe - .clone() - .ok_or_else(|| { - ToolError::Rejected( - "zsh fork feature enabled, but execve wrapper is not configured".to_string(), - ) - })?; - let exec_params = ExecParams { - command: script, - workdir: req.cwd.to_string_lossy().to_string(), - timeout_ms: Some(effective_timeout.as_millis() as u64), - login: Some(login), - }; - - // Note that Stopwatch starts immediately upon creation, so currently we try - // to minimize the time between creating the Stopwatch and starting the - // escalation server. - let stopwatch = Stopwatch::new(effective_timeout); - let mut cancel_token = stopwatch.cancellation_token(); - if let Some(cancellation) = attempt.network_denial_cancellation_token.clone() { - cancel_token = cancel_when_either(cancel_token, cancellation); - } - let approval_sandbox_permissions = approval_sandbox_permissions( - req.sandbox_permissions, - req.additional_permissions_preapproved, - ); - let escalation_policy = CoreShellActionProvider { - policy: Arc::clone(&exec_policy), - session: Arc::clone(&ctx.session), - review_context: GuardianReviewContext::from(&ctx.step_context), - call_id: ctx.call_id.clone(), - environment_id: req.turn_environment.selection.environment_id.clone(), - source: GuardianCommandSource::Shell, - tool_name: ctx.tool_name.clone(), - approval_policy: ctx.step_context.turn.approval_policy(), - permission_profile: command_executor.permission_profile.clone(), - sandbox_permissions: req.sandbox_permissions, - approval_sandbox_permissions, - prompt_permissions: req.additional_permissions.clone(), - stopwatch: stopwatch.clone(), - }; - - let escalate_server = EscalateServer::new( - shell_zsh_path.clone(), - main_execve_wrapper_exe, - escalation_policy, - ); - - let exec_result = escalate_server - .exec(exec_params, cancel_token, Arc::new(command_executor)) - .await - .map_err(|err| ToolError::Rejected(err.to_string()))?; - - map_exec_result(attempt.sandbox, exec_result).map(Some) -} - pub(crate) async fn prepare_unified_exec_zsh_fork( req: &crate::tools::runtimes::unified_exec::UnifiedExecRequest, _attempt: &SandboxAttempt<'_>, @@ -1024,36 +835,6 @@ fn extract_shell_script(command: &[String]) -> Result Result { - let output = ExecToolCallOutput { - exit_code: result.exit_code, - stdout: StreamOutput::new(result.stdout.clone()), - stderr: StreamOutput::new(result.stderr.clone()), - aggregated_output: StreamOutput::new(result.output.clone()), - duration: result.duration, - timed_out: result.timed_out, - }; - - if result.timed_out { - return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { - output: Box::new(output), - }))); - } - - if is_likely_sandbox_denied(sandbox, &output) { - record_filesystem_sandbox_violation(sandbox, &output); - return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { - output: Box::new(output), - network_policy_decision: None, - }))); - } - - Ok(output) -} - /// Convert an intercepted exec `(program, argv)` into a command vector suitable /// for display and policy parsing. /// diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index e0ff53b774..00f5f806ab 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -6,7 +6,6 @@ use super::commands_for_intercepted_exec_policy; use super::evaluate_intercepted_exec_policy; use super::extract_shell_script; use super::join_program_and_argv; -use super::map_exec_result; use crate::config::Constrained; use crate::guardian::GuardianReviewContext; use crate::sandboxing::SandboxPermissions; @@ -36,7 +35,6 @@ use codex_sandboxing::SandboxType; use codex_sandboxing::policy_transforms::effective_permission_profile; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; -use codex_shell_escalation::ExecResult; use codex_shell_escalation::ResolvedPermissionProfile; use codex_tools::ToolName; use codex_utils_absolute_path::AbsolutePathBuf; @@ -277,26 +275,6 @@ fn commands_for_intercepted_exec_policy_preserves_unparsed_shell_wrappers() { } } -#[test] -fn map_exec_result_preserves_stdout_and_stderr() { - let out = map_exec_result( - SandboxType::None, - ExecResult { - exit_code: 0, - stdout: "out".to_string(), - stderr: "err".to_string(), - output: "outerr".to_string(), - duration: Duration::from_millis(1), - timed_out: false, - }, - ) - .unwrap(); - - assert_eq!(out.stdout.text, "out"); - assert_eq!(out.stderr.text, "err"); - assert_eq!(out.aggregated_output.text, "outerr"); -} - #[test] fn shell_request_escalation_execution_is_explicit() { let requested_permissions = AdditionalPermissionProfile { @@ -445,8 +423,8 @@ async fn preapproved_additional_permissions_escalate_intercepted_exec() -> anyho review_context: GuardianReviewContext::from(Arc::new(turn_context)), call_id: "preapproved-additional-permissions".to_string(), environment_id: "local".to_string(), - source: GuardianCommandSource::Shell, - tool_name: ToolName::plain("shell_command"), + source: GuardianCommandSource::UnifiedExec, + tool_name: ToolName::plain("exec_command"), approval_policy: AskForApproval::OnRequest, permission_profile: permission_profile.clone(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, @@ -613,8 +591,8 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul review_context: GuardianReviewContext::from(turn_context), call_id: "execve-hook-call".to_string(), environment_id: "local".to_string(), - source: GuardianCommandSource::Shell, - tool_name: ToolName::plain("shell_command"), + source: GuardianCommandSource::UnifiedExec, + tool_name: ToolName::plain("exec_command"), approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -824,8 +802,8 @@ prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow") review_context: GuardianReviewContext::from(Arc::new(turn_context)), call_id: "deny-read-prefix-allow".to_string(), environment_id: "local".to_string(), - source: GuardianCommandSource::Shell, - tool_name: ToolName::plain("shell_command"), + source: GuardianCommandSource::UnifiedExec, + tool_name: ToolName::plain("exec_command"), approval_policy: AskForApproval::OnRequest, permission_profile, sandbox_permissions: SandboxPermissions::UseDefault, @@ -861,8 +839,8 @@ async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow review_context: GuardianReviewContext::from(Arc::new(turn_context)), call_id: "deny-read-granular-sandbox-reject".to_string(), environment_id: "local".to_string(), - source: GuardianCommandSource::Shell, - tool_name: ToolName::plain("shell_command"), + source: GuardianCommandSource::UnifiedExec, + tool_name: ToolName::plain("exec_command"), approval_policy: AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: false, rules: true, diff --git a/codex-rs/core/src/tools/runtimes/shell/zsh_fork_backend.rs b/codex-rs/core/src/tools/runtimes/shell/zsh_fork_backend.rs index 9d65924a89..a6aede84f6 100644 --- a/codex-rs/core/src/tools/runtimes/shell/zsh_fork_backend.rs +++ b/codex-rs/core/src/tools/runtimes/shell/zsh_fork_backend.rs @@ -1,12 +1,9 @@ -use super::ShellRequest; use crate::sandboxing::ExecRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRequest; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::unified_exec::SpawnLifecycleHandle; -use codex_core_plugins::PluginMetricsSidecar; -use codex_protocol::exec_output::ExecToolCallOutput; use codex_tools::ZshForkConfig; pub(crate) struct PreparedUnifiedExecSpawn { @@ -14,21 +11,6 @@ pub(crate) struct PreparedUnifiedExecSpawn { pub(crate) spawn_lifecycle: SpawnLifecycleHandle, } -/// Runs the zsh-fork shell-command backend when this request should be handled -/// by executable-level escalation instead of the default shell runtime. -/// -/// Returns `Ok(None)` when the current platform or request shape should fall -/// back to the normal shell-command path. -pub(crate) async fn maybe_run_shell_command( - req: &ShellRequest, - attempt: &SandboxAttempt<'_>, - ctx: &ToolCtx, - command: &[String], - metrics_sidecar: Option<&PluginMetricsSidecar>, -) -> Result, ToolError> { - imp::maybe_run_shell_command(req, attempt, ctx, command, metrics_sidecar).await -} - /// Prepares unified exec to launch through the zsh-fork backend when the /// request matches a wrapped `zsh -c/-lc` command on a supported platform. /// @@ -73,16 +55,6 @@ mod imp { } } - pub(super) async fn maybe_run_shell_command( - req: &ShellRequest, - attempt: &SandboxAttempt<'_>, - ctx: &ToolCtx, - command: &[String], - metrics_sidecar: Option<&PluginMetricsSidecar>, - ) -> Result, ToolError> { - unix_escalation::try_run_zsh_fork(req, attempt, ctx, command, metrics_sidecar).await - } - pub(super) async fn maybe_prepare_unified_exec( req: &UnifiedExecRequest, attempt: &SandboxAttempt<'_>, @@ -116,17 +88,6 @@ mod imp { mod imp { use super::*; - pub(super) async fn maybe_run_shell_command( - req: &ShellRequest, - attempt: &SandboxAttempt<'_>, - ctx: &ToolCtx, - command: &[String], - metrics_sidecar: Option<&PluginMetricsSidecar>, - ) -> Result, ToolError> { - let _ = (req, attempt, ctx, command, metrics_sidecar); - Ok(None) - } - pub(super) async fn maybe_prepare_unified_exec( req: &UnifiedExecRequest, attempt: &SandboxAttempt<'_>, diff --git a/codex-rs/core/src/tools/runtimes/shell_tests.rs b/codex-rs/core/src/tools/runtimes/shell_tests.rs deleted file mode 100644 index 217983a615..0000000000 --- a/codex-rs/core/src/tools/runtimes/shell_tests.rs +++ /dev/null @@ -1,81 +0,0 @@ -use super::*; -use crate::config::PermissionProfileSnapshot; -use crate::environment_selection::EnvironmentConfigOrigin; -use crate::tools::approvals::ApprovalCacheKey; -use codex_exec_server::Environment; -use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::EnvironmentConfig; -use codex_protocol::protocol::EnvironmentConfigState; -use codex_protocol::protocol::TurnEnvironmentSelection; -use codex_utils_path_uri::PathUri; -use pretty_assertions::assert_eq; -use std::sync::Arc; - -#[tokio::test] -async fn approval_key_uses_path_uri_and_includes_environment_id() { - let cwd = AbsolutePathBuf::try_from(std::env::current_dir().expect("read current dir")) - .expect("current dir is absolute"); - let mut request = ShellRequest { - command: vec!["echo".to_string(), "hello".to_string()], - turn_environment: TurnEnvironment::new( - TurnEnvironmentSelection { - environment_id: "remote".to_string(), - cwd: PathUri::from_abs_path(&cwd), - workspace_roots: Vec::new(), - config: EnvironmentConfigState::Ready(EnvironmentConfig { - allow_login_shell: true, - permission_profile: PermissionProfileSnapshot::legacy( - PermissionProfile::read_only(), - ), - shell_environment_policy: Default::default(), - exec_policy: None, - mcp_policy: None, - network_policy: None, - selected_capability_roots: Vec::new(), - }), - }, - EnvironmentConfigOrigin::Thread, - Arc::new(Environment::default_for_tests()), - /*shell*/ None, - ), - shell_type: None, - hook_command: "echo hello".to_string(), - cwd: cwd.clone(), - timeout_ms: None, - cancellation_token: CancellationToken::new(), - env: HashMap::new(), - explicit_env_overrides: HashMap::new(), - network: None, - sandbox_permissions: SandboxPermissions::UseDefault, - additional_permissions: None, - #[cfg(unix)] - additional_permissions_preapproved: false, - justification: None, - exec_approval_requirement: ExecApprovalRequirement::Skip { - bypass_sandbox: false, - proposed_execpolicy_amendment: None, - }, - }; - let runtime = ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic); - let original_key = runtime - .approval_action(&request, "call-1") - .expect("build approval action") - .cache_keys(); - assert_eq!( - original_key, - vec![ApprovalCacheKey::Shell(ApprovalKey { - environment_id: "remote".to_string(), - command: request.command.clone(), - cwd: PathUri::from_abs_path(&cwd), - sandbox_permissions: request.sandbox_permissions, - additional_permissions: request.additional_permissions.clone(), - })] - ); - request.turn_environment.selection.environment_id = "other".to_string(); - let other_key = runtime - .approval_action(&request, "call-1") - .expect("build approval action") - .cache_keys(); - - assert_ne!(original_key, other_key); -} diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index e34360a670..fe4110f36d 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -16,7 +16,6 @@ use crate::sandboxing::SandboxPermissions; use crate::session::turn_context::TurnEnvironment; use crate::shell::ShellType; use crate::tools::flat_tool_name; -use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::RuntimePathPrepends; #[cfg(unix)] @@ -226,7 +225,6 @@ impl<'a> ToolRuntime for UnifiedExecRunt managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)?; Some(NetworkApprovalSpec { network: Some(network.clone()), - mode: NetworkApprovalMode::Deferred, trigger: GuardianNetworkAccessTrigger { call_id: ctx.call_id.clone(), tool_name: flat_tool_name(&ctx.tool_name).into_owned(), diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 2141f3d30b..162781173b 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -25,8 +25,6 @@ use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestPluginInstallHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::SendUserMessageAsyncHandler; -use crate::tools::handlers::ShellCommandHandler; -use crate::tools::handlers::ShellCommandHandlerOptions; use crate::tools::handlers::SleepHandler; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::ToolSearchHandlerCache; @@ -92,7 +90,6 @@ use codex_tools::collect_code_mode_exec_prompt_tool_definitions; use codex_tools::collect_request_plugin_install_entries; use codex_tools::default_namespace_description; use codex_tools::request_user_input_available_modes; -use codex_tools::shell_command_backend_for_features; use codex_tools::shell_type_for_model_and_features; use futures::future::BoxFuture; use std::collections::BTreeMap; @@ -970,13 +967,6 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistr let allow_login_shell = any_environment_allows_login_shell(context.environments); let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals); let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple); - let supports_shell_command = context.environments.single_local_environment().is_some(); - let shell_command_options = ShellCommandHandlerOptions { - backend_config: shell_command_backend_for_features(features), - allow_login_shell, - exec_permission_approvals_enabled, - }; - match shell_type_for_model_and_features(&turn_context.model_info, features) { ConfigShellToolType::UnifiedExec => { registry.add(ExecCommandHandler::new(ExecCommandHandlerOptions { @@ -989,24 +979,9 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistr ), })); registry.add(WriteStdinHandler); - - if supports_shell_command { - // Keep the legacy shell tool registered while unified exec is - // model-visible. - registry.add_with_exposure( - ShellCommandHandler::new(shell_command_options), - ToolExposure::Hidden, - ); - } } ConfigShellToolType::Disabled => {} - ConfigShellToolType::Default - | ConfigShellToolType::Local - | ConfigShellToolType::ShellCommand => { - if supports_shell_command { - registry.add(ShellCommandHandler::new(shell_command_options)); - } - } + ConfigShellToolType::Default | ConfigShellToolType::Local => {} } } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 0283d6a2be..a2fcc037c6 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -660,34 +660,28 @@ async fn request_user_input_stays_direct_in_code_mode_only() { } #[tokio::test] -async fn shell_family_registers_visible_unified_exec_and_hidden_legacy_shell() { +async fn shell_family_registers_only_unified_exec_tools() { let plan = probe(|turn| { set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]); set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; }) .await; plan.assert_visible_contains(&["exec_command", "write_stdin"]); - plan.assert_visible_lacks(&["shell_command"]); - plan.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]); - assert_eq!(plan.exposure("shell_command"), ToolExposure::Hidden); + plan.assert_registered_contains(&["exec_command", "write_stdin"]); assert!(has_parameter(plan.visible_spec("exec_command"), "shell")); } #[tokio::test] async fn login_shell_parameter_follows_selected_environment() { - for (tool_name, guardian) in [ - ("shell_command", false), - ("exec_command", false), - ("exec_command", true), - ] { + for guardian in [false, true] { for allow_login_shell in [false, true] { let plan = probe(|turn| { set_feature(turn, Feature::ShellTool, /*enabled*/ true); - set_feature(turn, Feature::UnifiedExec, tool_name == "exec_command"); + set_feature(turn, Feature::UnifiedExec, /*enabled*/ true); set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; update_config(turn, |config| { config.permissions.allow_login_shell = !allow_login_shell; }); @@ -711,7 +705,7 @@ async fn login_shell_parameter_follows_selected_environment() { .await; assert_eq!( - has_parameter(plan.visible_spec(tool_name), "login"), + has_parameter(plan.visible_spec("exec_command"), "login"), allow_login_shell ); } @@ -740,11 +734,10 @@ async fn login_shell_parameter_is_available_when_any_environment_allows_it() { } #[tokio::test] -async fn shell_command_is_not_registered_without_a_single_local_environment() { +async fn disabling_shell_tools_disables_command_tools_for_all_environments() { let remote_environment = probe(|turn| { - set_feature(turn, Feature::ShellTool, /*enabled*/ true); - set_feature(turn, Feature::UnifiedExec, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; + set_feature(turn, Feature::ShellTool, /*enabled*/ false); + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; let TurnEnvironmentState::Ready(environment) = turn .environments @@ -767,64 +760,63 @@ async fn shell_command_is_not_registered_without_a_single_local_environment() { remote_environment.assert_registered_lacks(&["shell_command", "exec_command", "write_stdin"]); let multiple_local_environments = probe(|turn| { - set_feature(turn, Feature::ShellTool, /*enabled*/ true); - set_feature(turn, Feature::UnifiedExec, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; + set_feature(turn, Feature::ShellTool, /*enabled*/ false); + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; duplicate_primary_environment(turn); }) .await; - multiple_local_environments.assert_visible_lacks(&["shell_command"]); - multiple_local_environments.assert_registered_lacks(&["shell_command"]); + multiple_local_environments.assert_visible_lacks(&[ + "shell_command", + "exec_command", + "write_stdin", + ]); + multiple_local_environments.assert_registered_lacks(&[ + "shell_command", + "exec_command", + "write_stdin", + ]); } #[tokio::test] -async fn dynamic_tools_cannot_reclaim_the_reserved_shell_command_name() { +async fn dynamic_tools_cannot_reclaim_the_reserved_exec_command_name() { let plan = probe_with( duplicate_primary_environment, ToolPlanInputs { dynamic_tools: vec![ dynamic_tool( /*namespace*/ None, - "shell_command", - /*defer_loading*/ false, - ), - dynamic_tool( - Some("client"), - "shell_command", + "exec_command", /*defer_loading*/ false, ), + dynamic_tool(Some("client"), "exec_command", /*defer_loading*/ false), ], ..ToolPlanInputs::default() }, ) .await; - plan.assert_visible_lacks(&["shell_command"]); - plan.assert_registered_lacks(&["shell_command"]); + plan.assert_visible_contains(&["exec_command"]); + plan.assert_registered_contains(&["exec_command"]); plan.assert_visible_contains(&["client"]); - plan.assert_registered_contains( - &[&ToolName::namespaced("client", "shell_command").to_string()], - ); + plan.assert_registered_contains(&[&ToolName::namespaced("client", "exec_command").to_string()]); assert_eq!( plan.namespace_function_names("client"), - &["shell_command".to_string()] + &["exec_command".to_string()] ); } #[tokio::test] -async fn shell_zsh_fork_stays_standalone_until_unified_exec_composition_is_enabled() { - let standalone = probe(|turn| { +async fn shell_zsh_fork_keeps_unified_exec_available() { + let without_composition = probe(|turn| { set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]); set_feature(turn, Feature::ShellZshFork, /*enabled*/ true); set_feature(turn, Feature::UnifiedExecZshFork, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; }) .await; - standalone.assert_visible_contains(&["shell_command"]); - standalone.assert_visible_lacks(&["exec_command", "write_stdin"]); - standalone.assert_registered_contains(&["shell_command"]); - standalone.assert_registered_lacks(&["exec_command", "write_stdin"]); + without_composition.assert_visible_contains(&["exec_command", "write_stdin"]); + without_composition.assert_registered_contains(&["exec_command", "write_stdin"]); let composed = probe(|turn| { set_features( @@ -836,19 +828,12 @@ async fn shell_zsh_fork_stays_standalone_until_unified_exec_composition_is_enabl Feature::UnifiedExecZshFork, ], ); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; }) .await; - if codex_utils_pty::conpty_supported() { - composed.assert_visible_contains(&["exec_command", "write_stdin"]); - composed.assert_visible_lacks(&["shell_command"]); - composed.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]); - assert_eq!(composed.exposure("shell_command"), ToolExposure::Hidden); - } else { - composed.assert_visible_contains(&["shell_command"]); - composed.assert_visible_lacks(&["exec_command", "write_stdin"]); - } + composed.assert_visible_contains(&["exec_command", "write_stdin"]); + composed.assert_registered_contains(&["exec_command", "write_stdin"]); } #[tokio::test] @@ -958,15 +943,15 @@ async fn environment_count_controls_environment_backed_tools() { }) .await; no_environment.assert_visible_lacks(&[ - "shell_command", "exec_command", + "write_stdin", "apply_patch", "view_image", "request_permissions", ]); no_environment.assert_registered_lacks(&[ - "shell_command", "exec_command", + "write_stdin", "apply_patch", "view_image", "request_permissions", @@ -1363,7 +1348,7 @@ async fn unified_tool_runtimes_preserve_source_order_and_collision_priority() { |turn| { set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]); set_feature(turn, Feature::ShellZshFork, /*enabled*/ false); - Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand; + Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec; }, ToolPlanInputs { tool_runtimes: vec![mcp_runtime( diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 231f7f4166..f06ba73cb1 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -1001,21 +1001,21 @@ pub fn ev_apply_patch_custom_tool_call(call_id: &str, patch: &str) -> Value { }) } -pub fn ev_shell_command_call(call_id: &str, command: &str) -> Value { - let args = serde_json::json!({ "command": command }); - ev_shell_command_call_with_args(call_id, &args) +pub fn ev_exec_command_call(call_id: &str, command: &str) -> Value { + let args = serde_json::json!({ "cmd": command }); + ev_exec_command_call_with_args(call_id, &args) } -pub fn ev_shell_command_call_with_args(call_id: &str, args: &serde_json::Value) -> Value { - let arguments = serde_json::to_string(args).expect("serialize shell command arguments"); - ev_function_call(call_id, "shell_command", &arguments) +pub fn ev_exec_command_call_with_args(call_id: &str, args: &serde_json::Value) -> Value { + let arguments = serde_json::to_string(args).expect("serialize exec command arguments"); + ev_function_call(call_id, "exec_command", &arguments) } -pub fn ev_apply_patch_shell_command_call_via_heredoc(call_id: &str, patch: &str) -> Value { - let args = serde_json::json!({ "command": format!("apply_patch <<'EOF'\n{patch}\nEOF\n") }); +pub fn ev_apply_patch_exec_command_call_via_heredoc(call_id: &str, patch: &str) -> Value { + let args = serde_json::json!({ "cmd": format!("apply_patch <<'EOF'\n{patch}\nEOF\n") }); let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments"); - ev_function_call(call_id, "shell_command", &arguments) + ev_function_call(call_id, "exec_command", &arguments) } pub fn sse_failed(id: &str, code: &str, message: &str) -> String { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index cf2ad9a78c..de3a31b673 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -308,7 +308,7 @@ fn docker_command_capture_stdout(args: [&str; N]) -> Result= 0.1, "expected at least one tenth of a second of elapsed time, got {secs}" ); + codex.submit(Op::CleanBackgroundTerminals).await.unwrap(); } /// After an interrupt we persist a model-visible `` marker in the conversation @@ -164,13 +166,13 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { let call_id = "call-turn-aborted-marker"; let args = json!({ - "command": command, - "timeout_ms": 60_000 + "cmd": command, + "yield_time_ms": 60_000 }) .to_string(); let first_body = sse(vec![ ev_response_created("resp-marker"), - ev_function_call(call_id, "shell_command", &args), + ev_function_call(call_id, "exec_command", &args), ev_completed("resp-marker"), ]); let follow_up_body = sse(vec![ @@ -224,4 +226,5 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { .any(|text| text.contains("")), "expected marker in follow-up request" ); + codex.submit(Op::CleanBackgroundTerminals).await.unwrap(); } diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index d32f49b1b4..3b3b8afa00 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -9,8 +9,8 @@ use codex_protocol::user_input::UserInput; use core_test_support::responses::WebSocketConnectionConfig; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_exec_command_call; use core_test_support::responses::ev_response_created; -use core_test_support::responses::ev_shell_command_call; use core_test_support::responses::start_websocket_server; use core_test_support::responses::start_websocket_server_with_headers; use core_test_support::skip_if_no_network; @@ -119,11 +119,11 @@ async fn websocket_model_switch_to_responses_lite_omits_top_level_tools() -> Res async fn websocket_test_codex_shell_chain() -> Result<()> { skip_if_no_network!(Ok(())); - let call_id = "shell-command-call"; + let call_id = "exec-command-call"; let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), - ev_shell_command_call(call_id, "echo websocket"), + ev_exec_command_call(call_id, "echo websocket"), ev_completed("resp-1"), ], vec![ @@ -273,16 +273,16 @@ async fn websocket_first_turn_handles_handshake_delay_with_startup_prewarm() -> async fn websocket_v2_test_codex_shell_chain() -> Result<()> { skip_if_no_network!(Ok(())); - let call_id = "shell-command-call"; - let mut shell_command_call = ev_shell_command_call(call_id, "echo websocket"); - shell_command_call["item"]["id"] = serde_json::json!("fc_shell_command_call"); - shell_command_call["item"]["internal_chat_message_metadata_passthrough"] = + let call_id = "exec-command-call"; + let mut exec_command_call = ev_exec_command_call(call_id, "echo websocket"); + exec_command_call["item"]["id"] = serde_json::json!("fc_exec_command_call"); + exec_command_call["item"]["internal_chat_message_metadata_passthrough"] = serde_json::json!({"turn_id": "turn-123"}); let server = start_websocket_server(vec![vec![ vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), - shell_command_call, + exec_command_call, ev_completed("resp-1"), ], vec![ diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index aba1a9e3e1..b5dda68de7 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -4,8 +4,8 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_core::StartThreadOptions; use codex_core::TurnInputRequest; use core_test_support::responses::ev_apply_patch_custom_tool_call; -use core_test_support::responses::ev_apply_patch_shell_command_call_via_heredoc; -use core_test_support::responses::ev_shell_command_call; +use core_test_support::responses::ev_apply_patch_exec_command_call_via_heredoc; +use core_test_support::responses::ev_exec_command_call; use core_test_support::test_codex::ApplyPatchModelOutput; use pretty_assertions::assert_eq; use std::fs; @@ -53,9 +53,9 @@ use core_test_support::TestTargetOs; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_exec_command_call_with_args; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; -use core_test_support::responses::ev_shell_command_call_with_args; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; @@ -251,8 +251,8 @@ async fn mount_apply_patch_model_output( model_output: ApplyPatchModelOutput, ) { let apply_patch_call = match model_output { - ApplyPatchModelOutput::ShellCommandViaHeredoc => { - ev_apply_patch_shell_command_call_via_heredoc + ApplyPatchModelOutput::ExecCommandViaHeredoc => { + ev_apply_patch_exec_command_call_via_heredoc } }; @@ -300,13 +300,13 @@ async fn assert_apply_patch_crlf_update( CrLfApplyPatchModelOutput::CustomTool => { mount_apply_patch(&harness, call_id, &patch, "apply_patch done").await; } - CrLfApplyPatchModelOutput::ShellCommandViaHeredoc => { + CrLfApplyPatchModelOutput::ExecCommandViaHeredoc => { mount_apply_patch_model_output( &harness, call_id, &patch, "apply_patch done", - ApplyPatchModelOutput::ShellCommandViaHeredoc, + ApplyPatchModelOutput::ExecCommandViaHeredoc, ) .await; } @@ -327,7 +327,7 @@ async fn assert_apply_patch_crlf_update( #[derive(Clone, Copy)] enum CrLfApplyPatchModelOutput { CustomTool, - ShellCommandViaHeredoc, + ExecCommandViaHeredoc, } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -363,7 +363,7 @@ async fn apply_patch_shell_heredoc_normalizes_crlf_without_preserve_line_endings skip_if_wine_exec!(Ok(()), "uses a POSIX shell heredoc"); assert_apply_patch_crlf_update( |builder| builder, - CrLfApplyPatchModelOutput::ShellCommandViaHeredoc, + CrLfApplyPatchModelOutput::ExecCommandViaHeredoc, "after\n", ) .await @@ -382,7 +382,7 @@ async fn apply_patch_shell_heredoc_preserves_crlf_with_preserve_line_endings_fea .expect("feature should be enabled"); }) }, - CrLfApplyPatchModelOutput::ShellCommandViaHeredoc, + CrLfApplyPatchModelOutput::ExecCommandViaHeredoc, "after\r\n", ) .await @@ -907,7 +907,7 @@ async fn intercepted_apply_patch_verification_uses_local_sandbox() -> Result<()> call_id, &patch, "fail", - ApplyPatchModelOutput::ShellCommandViaHeredoc, + ApplyPatchModelOutput::ExecCommandViaHeredoc, ) .await; @@ -1330,7 +1330,7 @@ async fn apply_patch_cli_verification_failure_has_no_side_effects() -> Result<() } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() -> Result<()> { +async fn apply_patch_exec_command_heredoc_with_cd_updates_relative_workdir() -> Result<()> { // TODO(anp): Remove after apply_patch shell fixtures use target-native commands. skip_if_wine_exec!(Ok(()), "uses a POSIX shell heredoc and cd command"); skip_if_no_network!(Ok(())); @@ -1345,7 +1345,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() -> let bodies = vec![ sse(vec![ ev_response_created("resp-1"), - ev_shell_command_call(call_id, script), + ev_exec_command_call(call_id, script), ev_completed("resp-1"), ]), sse(vec![ @@ -1360,18 +1360,18 @@ async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() -> let out = harness.function_call_stdout(call_id).await; assert!( out.contains("Success."), - "expected successful apply_patch invocation via shell_command: {out}" + "expected successful apply_patch invocation via exec_command: {out}" ); assert_eq!(harness.read_file_text("sub/in_sub.txt").await?, "after\n"); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result<()> { +async fn apply_patch_cli_can_use_exec_command_output_as_patch_input() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_remote!( Ok(()), - "shell_command output producer runs in the test runner, not in the remote apply_patch workspace", + "exec_command output producer runs in the test runner, not in the remote apply_patch workspace", ); let harness = @@ -1438,12 +1438,12 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result "cat source.txt".to_string() }; let args = json!({ - "command": command, + "cmd": command, "login": false, }); let body = sse(vec![ ev_response_created("resp-1"), - ev_shell_command_call_with_args(&self.read_call_id, &args), + ev_exec_command_call_with_args(&self.read_call_id, &args), ev_completed("resp-1"), ]); ResponseTemplate::new(200) @@ -1614,7 +1614,7 @@ async fn apply_patch_custom_tool_streaming_emits_updated_changes() -> Result<()> } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<()> { +async fn apply_patch_exec_command_heredoc_with_cd_emits_turn_diff() -> Result<()> { // TODO(anp): Remove after apply_patch shell fixtures use target-native commands. skip_if_wine_exec!(Ok(()), "uses a POSIX shell heredoc and cd command"); skip_if_no_network!(Ok(())); @@ -1628,11 +1628,11 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<( let script = "cd sub && apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: in_sub.txt\n@@\n-before\n+after\n*** End Patch\nEOF\n"; let call_id = "shell-heredoc-cd"; - let args = json!({ "command": script, "timeout_ms": 30_000 }); + let args = json!({ "cmd": script, "yield_time_ms": 30_000 }); let bodies = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -1866,7 +1866,7 @@ async fn apply_patch_turn_diff_skips_git_root_when_feature_is_enabled( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> Result<()> { +async fn apply_patch_exec_command_failure_propagates_error_and_skips_diff() -> Result<()> { // TODO(anp): Remove after apply_patch shell fixtures use target-native commands. skip_if_wine_exec!(Ok(()), "uses a POSIX shell heredoc"); skip_if_no_network!(Ok(())); @@ -1879,11 +1879,11 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> let script = "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: invalid.txt\n@@\n-nope\n+changed\n*** End Patch\nEOF\n"; let call_id = "shell-apply-failure"; - let args = json!({ "command": script, "timeout_ms": 5_000 }); + let args = json!({ "cmd": script, "yield_time_ms": 5_000 }); let bodies = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -1941,7 +1941,7 @@ async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch() -> Result<()> call_id, patch_inner.as_str(), "ok", - ApplyPatchModelOutput::ShellCommandViaHeredoc, + ApplyPatchModelOutput::ExecCommandViaHeredoc, ) .await; diff --git a/codex-rs/core/tests/suite/apply_patch_serialization.rs b/codex-rs/core/tests/suite/apply_patch_serialization.rs new file mode 100644 index 0000000000..31b0bc6ff1 --- /dev/null +++ b/codex-rs/core/tests/suite/apply_patch_serialization.rs @@ -0,0 +1,127 @@ +#![cfg(not(target_os = "windows"))] + +use anyhow::Result; +use codex_protocol::models::PermissionProfile; +use core_test_support::assert_regex_match; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_target_windows; +use pretty_assertions::assert_eq; + +use crate::suite::apply_patch_cli::apply_patch_harness; +use crate::suite::apply_patch_cli::mount_apply_patch; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_custom_tool_call_creates_file() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let call_id = "apply-patch-add-file"; + let file_name = "custom_tool_apply_patch.txt"; + let patch = format!( + "*** Begin Patch\n*** Add File: {file_name}\n+custom tool content\n*** End Patch\n" + ); + mount_apply_patch(&harness, call_id, &patch, "apply_patch done").await; + + harness + .test() + .submit_turn_with_permission_profile( + "apply the patch via custom tool to create a file", + PermissionProfile::Disabled, + ) + .await?; + + let output = harness.apply_patch_output(call_id).await; + + let expected_pattern = format!( + r"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +Success. Updated the following files: +A {file_name} +?$" + ); + assert_regex_match(&expected_pattern, output.as_str()); + + let created_contents = harness.read_file_text(file_name).await?; + assert_eq!( + created_contents, "custom tool content\n", + "expected file contents for {file_name}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_custom_tool_call_updates_existing_file() -> Result<()> { + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let call_id = "apply-patch-update-file"; + let file_name = "custom_tool_apply_patch_existing.txt"; + harness.write_file(file_name, "before\n").await?; + let patch = format!( + "*** Begin Patch\n*** Update File: {file_name}\n@@\n-before\n+after\n*** End Patch\n" + ); + mount_apply_patch(&harness, call_id, &patch, "apply_patch update done").await; + + harness + .test() + .submit_turn_with_permission_profile( + "apply the patch via custom tool to update a file", + PermissionProfile::Disabled, + ) + .await?; + + let output = harness.apply_patch_output(call_id).await; + + let expected_pattern = format!( + r"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +Success. Updated the following files: +M {file_name} +?$" + ); + assert_regex_match(&expected_pattern, output.as_str()); + + let updated_contents = harness.read_file_text(file_name).await?; + assert_eq!(updated_contents, "after\n", "expected updated file content"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_custom_tool_call_reports_failure_output() -> Result<()> { + // TODO(anp): Remove after apply-patch assertions use target-native paths. + skip_if_target_windows!(Ok(()), "asserts POSIX apply_patch failure text"); + skip_if_no_network!(Ok(())); + + let harness = apply_patch_harness().await?; + + let call_id = "apply-patch-failure"; + let missing_file = "missing_custom_tool_apply_patch.txt"; + let patch = format!( + "*** Begin Patch\n*** Update File: {missing_file}\n@@\n-before\n+after\n*** End Patch\n" + ); + mount_apply_patch(&harness, call_id, &patch, "apply_patch failure done").await; + + harness + .test() + .submit_turn_with_permission_profile( + "attempt a failing apply_patch via custom tool", + PermissionProfile::Disabled, + ) + .await?; + + let output = harness.apply_patch_output(call_id).await; + + let expected_output = format!( + "apply_patch verification failed: Failed to read file to update {}/{missing_file}: No such file or directory (os error 2)", + harness.cwd().to_string_lossy() + ); + assert_eq!(output, expected_output.as_str()); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index ec2b62375c..c5b67c61b5 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -342,17 +342,18 @@ fn shell_event_with_prefix_rule( prefix_rule: Option>, ) -> Result { let mut args = json!({ - "command": command, - "timeout_ms": timeout_ms, + "cmd": command, + "yield_time_ms": timeout_ms, }); if sandbox_permissions.requests_sandbox_override() { args["sandbox_permissions"] = json!(sandbox_permissions); + args["justification"] = json!(DEFAULT_UNIFIED_EXEC_JUSTIFICATION); } if let Some(prefix_rule) = prefix_rule { args["prefix_rule"] = json!(prefix_rule); } let args_str = serde_json::to_string(&args)?; - Ok(ev_function_call(call_id, "shell_command", &args_str)) + Ok(ev_function_call(call_id, "exec_command", &args_str)) } fn exec_command_event( @@ -1091,7 +1092,7 @@ fn scenarios() -> Vec { model_override: Some("gpt-5.2"), outcome: Outcome::Auto, expectation: Expectation::CommandFailure { - output_contains: "you should not ask for escalated permissions", + output_contains: "you cannot ask for escalated permissions", }, }, ScenarioSpec { @@ -1353,7 +1354,7 @@ fn scenarios() -> Vec { }, expectation: Expectation::FileNotCreated { target: TargetPath::Workspace("ro_on_request_denied.txt"), - message_contains: &["exec command rejected by user"], + message_contains: &["rejected by user"], }, }, ScenarioSpec { @@ -1395,7 +1396,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "apply_patch_shell_command_requires_patch_approval", + name: "apply_patch_exec_command_requires_patch_approval", approval_policy: UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::ApplyPatchShell { @@ -1489,7 +1490,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "apply_patch_shell_command_outside_requires_patch_approval", + name: "apply_patch_exec_command_outside_requires_patch_approval", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::ApplyPatchShell { @@ -1547,46 +1548,6 @@ fn scenarios() -> Vec { ], }, }, - ScenarioSpec { - name: "read_only_unless_trusted_requires_approval", - approval_policy: UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - action: ActionKind::WriteFile { - target: TargetPath::Workspace("ro_unless_trusted.txt"), - content: "read-only-unless-trusted", - }, - sandbox_permissions: SandboxPermissions::UseDefault, - features: vec![], - model_override: Some("gpt-5.2"), - outcome: Outcome::ExecApproval { - decision: ReviewDecision::Approved, - expected_reason: None, - }, - expectation: Expectation::FileCreated { - target: TargetPath::Workspace("ro_unless_trusted.txt"), - content: "read-only-unless-trusted", - }, - }, - ScenarioSpec { - name: "read_only_unless_trusted_requires_approval_gpt_5_1_no_exit", - approval_policy: UnlessTrusted, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - action: ActionKind::WriteFile { - target: TargetPath::Workspace("ro_unless_trusted_5_1.txt"), - content: "read-only-unless-trusted", - }, - sandbox_permissions: SandboxPermissions::UseDefault, - features: vec![], - model_override: Some("gpt-5.4"), - outcome: Outcome::ExecApproval { - decision: ReviewDecision::Approved, - expected_reason: None, - }, - expectation: Expectation::FileCreatedNoExitCode { - target: TargetPath::Workspace("ro_unless_trusted_5_1.txt"), - content: "read-only-unless-trusted", - }, - }, ScenarioSpec { name: "read_only_never_reports_sandbox_failure", approval_policy: Never, @@ -1693,26 +1654,6 @@ fn scenarios() -> Vec { body_contains: "workspace-network-ok", }, }, - ScenarioSpec { - name: "workspace_write_unless_trusted_requires_approval_outside_workspace", - approval_policy: UnlessTrusted, - sandbox_policy: workspace_write(false), - action: ActionKind::WriteFile { - target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), - content: "workspace-unless-trusted", - }, - sandbox_permissions: SandboxPermissions::UseDefault, - features: vec![], - model_override: Some("gpt-5.2"), - outcome: Outcome::ExecApproval { - decision: ReviewDecision::Approved, - expected_reason: None, - }, - expectation: Expectation::FileCreated { - target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"), - content: "workspace-unless-trusted", - }, - }, ScenarioSpec { name: "workspace_write_never_blocks_outside_workspace", approval_policy: Never, @@ -2472,7 +2413,7 @@ async fn assert_execpolicy_amendment_context( async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::UnlessTrusted; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -2645,7 +2586,7 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> let server = start_mock_server().await; let approval_policy = AskForApproval::UnlessTrusted; - let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); @@ -2688,8 +2629,8 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> .await; let child_cmd_args = serde_json::to_string(&json!({ - "command": "touch subagent-allow-prefix.txt", - "timeout_ms": 1_000, + "cmd": "touch subagent-allow-prefix.txt", + "yield_time_ms": 10_000, "prefix_rule": ["touch", "subagent-allow-prefix.txt"], }))?; mount_sse_once_match( @@ -2697,7 +2638,7 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> |req: &Request| body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID), sse(vec![ ev_response_created("resp-child-1"), - ev_function_call(CHILD_CALL_ID_1, "shell_command", &child_cmd_args), + ev_function_call(CHILD_CALL_ID_1, "exec_command", &child_cmd_args), ev_completed("resp-child-1"), ]), ) @@ -2729,7 +2670,7 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> &server, sse(vec![ ev_response_created("resp-parent-3"), - ev_function_call(PARENT_CALL_ID_2, "shell_command", &child_cmd_args), + ev_function_call(PARENT_CALL_ID_2, "exec_command", &child_cmd_args), ev_completed("resp-parent-3"), ]), ) @@ -3095,7 +3036,7 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { /// `:workspace` sandbox, while its inherited profile name remains `:workspace`. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[cfg(unix)] -async fn allowed_escalated_shell_command_inherits_active_permission_profile() -> Result<()> { +async fn allowed_escalated_exec_command_inherits_active_permission_profile() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -3477,7 +3418,7 @@ async fn approving_fallback_rule_for_compound_command_works() -> Result<()> { let event = shell_event_with_prefix_rule( call_id, command, - /*timeout_ms*/ 1_000, + /*timeout_ms*/ 10_000, SandboxPermissions::RequireEscalated, Some(vec!["touch".to_string()]), )?; @@ -3524,7 +3465,7 @@ async fn approving_fallback_rule_for_compound_command_works() -> Result<()> { let event = shell_event_with_prefix_rule( call_id, command, - /*timeout_ms*/ 1_000, + /*timeout_ms*/ 10_000, SandboxPermissions::RequireEscalated, Some(vec!["touch".to_string()]), )?; diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 2509fddd83..bfcf827bb5 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -501,7 +501,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod effort: ReasoningEffort::Medium, description: ReasoningEffort::Medium.to_string(), }], - shell_type: ConfigShellToolType::ShellCommand, + shell_type: ConfigShellToolType::UnifiedExec, visibility: ModelVisibility::List, supported_in_api: true, input_modalities: default_input_modalities(), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 0c1cd7ef85..39c82af9f6 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -383,7 +383,7 @@ async fn missing_process_host_keeps_code_mode_only_and_fails_closed() -> Result< assert!( tools .iter() - .all(|name| { !matches!(name.as_str(), "shell" | "shell_command" | "exec_command") }), + .all(|name| !matches!(name.as_str(), "shell" | "exec_command")), "code-mode-only must never expose direct shell tools: {tools:?}" ); let (output, _) = custom_tool_output_body_and_success(&request, "call-1"); diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index a7e0edab1b..794afe259d 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -99,14 +99,24 @@ const REMOTE_V2_SUMMARY: &str = "global-instructions-remote-v2-summary"; pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted."; -fn ev_shell_command_call(call_id: &str, command: &str) -> serde_json::Value { +fn ev_exec_command_call(call_id: &str, command: &str) -> serde_json::Value { ev_function_call( call_id, - "shell_command", - &json!({ "command": command }).to_string(), + "exec_command", + &json!({ "cmd": command }).to_string(), ) } +pub(super) fn allow_echo_commands(home: &Path) { + let rules_dir = home.join("rules"); + fs::create_dir_all(&rules_dir).expect("create exec policy rules directory"); + fs::write( + rules_dir.join("default.rules"), + r#"prefix_rule(pattern=["echo"], decision="allow")"#, + ) + .expect("write echo exec policy rule"); +} + fn disabled_permission_user_turn( text: impl Into, cwd: PathBuf, @@ -1042,6 +1052,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { let non_openai_provider_name = non_openai_model_provider(&server).name; let test = test_codex() + .with_pre_build_hook(allow_echo_commands) .with_config(move |config| { config.model_provider.name = non_openai_provider_name; }) @@ -1077,7 +1088,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // first chunk of work let model_reasoning_response_1_sse = sse(vec![ reasoning_response_1.clone(), - ev_shell_command_call("r1-shell", "echo make-react"), + ev_exec_command_call("r1-shell", "echo make-react"), ev_completed_with_tokens("r1", token_count_used), ]); @@ -1095,7 +1106,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // second chunk of work let model_reasoning_response_2_sse = sse(vec![ reasoning_response_2.clone(), - ev_shell_command_call("r3-shell", "echo make-node"), + ev_exec_command_call("r3-shell", "echo make-node"), ev_completed_with_tokens("r3", token_count_used), ]); @@ -1113,7 +1124,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { // third chunk of work let model_reasoning_response_3_sse = sse(vec![ ev_reasoning_item("m6", &["I will create a python app"], &[]), - ev_shell_command_call("r6-shell", "echo make-python"), + ev_exec_command_call("r6-shell", "echo make-python"), ev_completed_with_tokens("r6", token_count_used), ]); @@ -1308,9 +1319,9 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { "type": "reasoning" }, { - "arguments": "{\"command\":\"echo make-react\"}", + "arguments": "{\"cmd\":\"echo make-react\"}", "call_id": "r1-shell", - "name": "shell_command", + "name": "exec_command", "type": "function_call" }, { @@ -1408,9 +1419,9 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { "type": "reasoning" }, { - "arguments": "{\"command\":\"echo make-node\"}", + "arguments": "{\"cmd\":\"echo make-node\"}", "call_id": "r3-shell", - "name": "shell_command", + "name": "exec_command", "type": "function_call" }, { @@ -1508,9 +1519,9 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { "type": "reasoning" }, { - "arguments": "{\"command\":\"echo make-python\"}", + "arguments": "{\"cmd\":\"echo make-python\"}", "call_id": "r6-shell", - "name": "shell_command", + "name": "exec_command", "type": "function_call" }, { diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 8c7dd119b1..c1e973deb9 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1,3 +1,4 @@ +use super::compact::allow_echo_commands; use core_test_support::test_codex::local_selections; use std::fs; use std::path::Path; @@ -1041,7 +1042,9 @@ async fn assert_remote_manual_compact_request_parity( scenario: &str, ) -> Result<()> { let uses_codex_backend = auth.uses_codex_backend(); - let mut builder = test_codex().with_auth(auth); + let mut builder = test_codex() + .with_auth(auth) + .with_pre_build_hook(allow_echo_commands); if let Some(service_tier) = configured_service_tier { builder = builder.with_config(move |config| { config.service_tier = Some(service_tier.request_value().to_string()); @@ -1078,8 +1081,8 @@ async fn assert_remote_manual_compact_request_parity( responses::ev_completed("turn-three-final-response"), ]), responses::sse(vec![ - responses::ev_shell_command_call( - "turn-four-shell-command", + responses::ev_exec_command_call( + "turn-four-exec-command", "echo TURN_FOUR_LOCAL_SHELL", ), responses::ev_completed("turn-four-local-shell-response"), @@ -2025,7 +2028,9 @@ async fn remote_compact_runs_automatically() -> Result<()> { skip_if_no_network!(Ok(())); let harness = TestCodexHarness::with_builder( - test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()), + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_pre_build_hook(allow_echo_commands), ) .await?; let codex = harness.test().codex.clone(); @@ -2035,7 +2040,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { let initial_request = mount_sse_once( harness.server(), sse(vec![ - responses::ev_shell_command_call("m1", "echo 'hi'"), + responses::ev_exec_command_call("m1", "echo 'hi'"), responses::ev_completed_with_tokens("resp-1", /*total_tokens*/ 100000000), // over token limit ]), ) @@ -2171,7 +2176,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R harness.server(), vec![ sse(vec![ - responses::ev_shell_command_call(retained_call_id, retained_command), + responses::ev_exec_command_call(retained_call_id, retained_command), responses::ev_completed("retained-call-response"), ]), sse(vec![ @@ -2179,7 +2184,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R responses::ev_completed("retained-final-response"), ]), sse(vec![ - responses::ev_shell_command_call(trimmed_call_id, trimmed_command), + responses::ev_exec_command_call(trimmed_call_id, trimmed_command), responses::ev_completed("trimmed-call-response"), ]), ], @@ -2286,7 +2291,7 @@ async fn remote_compact_rewrites_multiple_trailing_function_call_outputs() -> Re harness.server(), vec![ sse(vec![ - responses::ev_shell_command_call(retained_call_id, retained_command), + responses::ev_exec_command_call(retained_call_id, retained_command), responses::ev_completed("retained-call-response"), ]), sse(vec![ @@ -2294,8 +2299,8 @@ async fn remote_compact_rewrites_multiple_trailing_function_call_outputs() -> Re responses::ev_completed("retained-final-response"), ]), sse(vec![ - responses::ev_shell_command_call(first_trimmed_call_id, first_trimmed_command), - responses::ev_shell_command_call(second_trimmed_call_id, second_trimmed_command), + responses::ev_exec_command_call(first_trimmed_call_id, first_trimmed_command), + responses::ev_exec_command_call(second_trimmed_call_id, second_trimmed_command), responses::ev_completed("parallel-call-response"), ]), ], @@ -2391,7 +2396,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() harness.server(), vec![ sse(vec![ - responses::ev_shell_command_call(retained_call_id, retained_command), + responses::ev_exec_command_call(retained_call_id, retained_command), responses::ev_completed_with_tokens( "retained-call-response", /*total_tokens*/ 100, @@ -2402,7 +2407,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() responses::ev_completed("retained-final-response"), ]), sse(vec![ - responses::ev_shell_command_call(trimmed_call_id, trimmed_command), + responses::ev_exec_command_call(trimmed_call_id, trimmed_command), responses::ev_completed_with_tokens( "trimmed-call-response", /*total_tokens*/ 100, @@ -2715,7 +2720,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result baseline_harness.server(), vec![ sse(vec![ - responses::ev_shell_command_call(baseline_retained_call_id, retained_command), + responses::ev_exec_command_call(baseline_retained_call_id, retained_command), responses::ev_completed("baseline-retained-call-response"), ]), sse(vec![ @@ -2723,7 +2728,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result responses::ev_completed("baseline-retained-final-response"), ]), sse(vec![ - responses::ev_shell_command_call(baseline_trailing_call_id, trailing_command), + responses::ev_exec_command_call(baseline_trailing_call_id, trailing_command), responses::ev_completed("baseline-trailing-call-response"), ]), sse(vec![responses::ev_completed( @@ -2811,7 +2816,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result override_harness.server(), vec![ sse(vec![ - responses::ev_shell_command_call(override_retained_call_id, retained_command), + responses::ev_exec_command_call(override_retained_call_id, retained_command), responses::ev_completed("override-retained-call-response"), ]), sse(vec![ @@ -2819,7 +2824,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result responses::ev_completed("override-retained-final-response"), ]), sse(vec![ - responses::ev_shell_command_call(override_trailing_call_id, trailing_command), + responses::ev_exec_command_call(override_trailing_call_id, trailing_command), responses::ev_completed("override-trailing-call-response"), ]), sse(vec![responses::ev_completed( @@ -4558,6 +4563,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec let harness = TestCodexHarness::with_builder( test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_pre_build_hook(allow_echo_commands) .with_config(|config| { config.model_auto_compact_token_limit = Some(200); }), @@ -4576,7 +4582,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec let second_turn_request_mock = responses::mount_sse_once( harness.server(), responses::sse(vec![ - responses::ev_shell_command_call("call-remote-multi-summary", "echo multi-summary"), + responses::ev_exec_command_call("call-remote-multi-summary", "echo multi-summary"), responses::ev_completed_with_tokens("r1", /*total_tokens*/ 1_000), ]), ) diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 971d46e5c0..e221af2dc4 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -1,3 +1,4 @@ +use super::compact::allow_echo_commands; use codex_core::TurnInputRequest; use std::fs; use std::path::Path; @@ -514,6 +515,7 @@ async fn build_harness_inner( fs::create_dir_all(FIXED_CWD)?; let mut builder = test_codex() .with_auth(settings.auth.build()) + .with_pre_build_hook(allow_echo_commands) .with_pre_build_hook(|home| { fs::write(home.join("AGENTS.md"), USER_INSTRUCTIONS) .expect("write global instructions"); @@ -690,7 +692,7 @@ fn response_bodies_for_step(scenario_name: &str, idx: usize, step: Step) -> Vec< ], Step::ShellTool => vec![ responses::sse(vec![ - responses::ev_shell_command_call( + responses::ev_exec_command_call( &format!("{response_id}-shell-call"), &format!("echo {scenario_name}_{idx}_SHELL_TOOL"), ), @@ -948,6 +950,22 @@ fn normalize_string(value: &str) -> String { normalize_tmp_prefix_before_marker(&mut text, "/skills/"); normalize_tmp_prefix_before_marker(&mut text, "\\skills\\"); + let mut search_start = 0; + let chunk_id_prefix = "Chunk ID: "; + while let Some(relative_start) = text[search_start..].find(chunk_id_prefix) { + let value_start = search_start + relative_start + chunk_id_prefix.len(); + let value_end = text[value_start..] + .find('\n') + .map_or(text.len(), |offset| value_start + offset); + let value = &text[value_start..value_end]; + if !value.is_empty() && value.chars().all(|ch| ch.is_ascii_hexdigit()) { + text.replace_range(value_start..value_end, ""); + search_start = value_start + "".len(); + } else { + search_start = value_end; + } + } + let skills_open_tag = ""; let skills_close_tag = ""; let mut search_start = 0; diff --git a/codex-rs/core/tests/suite/current_time_reminder.rs b/codex-rs/core/tests/suite/current_time_reminder.rs index 2cdac3c78e..937cae27c3 100644 --- a/codex-rs/core/tests/suite/current_time_reminder.rs +++ b/codex-rs/core/tests/suite/current_time_reminder.rs @@ -174,8 +174,8 @@ async fn current_time_reminders_follow_time_interval_and_persist_in_history() -> let server = start_mock_server().await; let tool_args = json!({ - "command": "echo current time", - "timeout_ms": 1_000, + "cmd": "echo current time", + "yield_time_ms": 1_000, }); let responses = mount_sse_sequence( &server, @@ -184,7 +184,7 @@ async fn current_time_reminders_follow_time_interval_and_persist_in_history() -> ev_response_created("resp-1"), ev_function_call( "current-time-tool-call", - "shell_command", + "exec_command", &serde_json::to_string(&tool_args)?, ), ev_completed("resp-1"), @@ -265,8 +265,8 @@ async fn current_time_reminders_can_follow_only_user_or_tool_outputs() -> Result let server = start_mock_server().await; let tool_args = json!({ - "command": "echo current time", - "timeout_ms": 1_000, + "cmd": "echo current time", + "yield_time_ms": 1_000, }); let mut continue_response = ev_completed("resp-2"); // Ask for another inference without recording a new user message or tool output. @@ -278,7 +278,7 @@ async fn current_time_reminders_can_follow_only_user_or_tool_outputs() -> Result ev_response_created("resp-1"), ev_function_call( "current-time-tool-call", - "shell_command", + "exec_command", &serde_json::to_string(&tool_args)?, ), ev_completed("resp-1"), diff --git a/codex-rs/core/tests/suite/cyber_exec_policy.rs b/codex-rs/core/tests/suite/cyber_exec_policy.rs index 183c0b6784..10ff99db2c 100644 --- a/codex-rs/core/tests/suite/cyber_exec_policy.rs +++ b/codex-rs/core/tests/suite/cyber_exec_policy.rs @@ -41,7 +41,6 @@ const SAVED_PREFIX: &str = r#"["git", "version"]"#; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum CommandTool { - ShellCommand, UnifiedExec, } @@ -92,7 +91,6 @@ fn configure_saved_prefix_and_guardian(config: &mut Config) { fn command_response(response_id: &str, call_id: &str, command_tool: CommandTool) -> Result { let (tool_name, command_key) = match command_tool { - CommandTool::ShellCommand => ("shell_command", "command"), CommandTool::UnifiedExec => ("exec_command", "cmd"), }; let mut args = json!({ @@ -130,11 +128,8 @@ async fn submit_model_turn(test: &TestCodex, model: &str, prompt: &str) -> Resul test.submit_text_turn(prompt).await } -#[test_case(CommandTool::ShellCommand, ModelSpecialty::Cyber, ShellBackend::Standard; "cyber shell command is reviewed")] #[test_case(CommandTool::UnifiedExec, ModelSpecialty::Cyber, ShellBackend::Standard; "cyber unified exec is reviewed")] -#[test_case(CommandTool::ShellCommand, ModelSpecialty::Cyber, ShellBackend::ZshFork; "cyber zsh shell command is reviewed")] #[test_case(CommandTool::UnifiedExec, ModelSpecialty::Cyber, ShellBackend::ZshFork; "cyber zsh unified exec is reviewed")] -#[test_case(CommandTool::ShellCommand, ModelSpecialty::General, ShellBackend::Standard; "general shell command keeps saved approval")] #[test_case(CommandTool::UnifiedExec, ModelSpecialty::General, ShellBackend::Standard; "general unified exec keeps saved approval")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn saved_prefix_only_bypasses_guardian_for_general_models( @@ -222,7 +217,6 @@ async fn saved_prefix_only_bypasses_guardian_for_general_models( Ok(()) } -#[test_case(CommandTool::ShellCommand; "shell command")] #[test_case(CommandTool::UnifiedExec; "unified exec")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cyber_model_user_approval_never_offers_a_reusable_prefix( @@ -331,20 +325,20 @@ async fn switching_models_suppresses_and_restores_saved_prefix_approvals() -> Re command_response( "parent-general-first-command", "general-first-command", - CommandTool::ShellCommand, + CommandTool::UnifiedExec, )?, sse_completed("parent-general-first-complete"), command_response( "parent-cyber-command", "cyber-command", - CommandTool::ShellCommand, + CommandTool::UnifiedExec, )?, guardian_allow_response("guardian-cyber-review"), sse_completed("parent-cyber-complete"), command_response( "parent-general-last-command", "general-last-command", - CommandTool::ShellCommand, + CommandTool::UnifiedExec, )?, sse_completed("parent-general-last-complete"), ], diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 3c8789f6a8..ed80250f09 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -246,15 +246,15 @@ async fn granular_complex_forced_rm_denial_explains_why_the_command_was_rejected let test = builder.build_with_auto_env(&server).await?; let call_id = "forced-rm-denied"; let args = json!({ - "command": COMPLEX_FORCED_RM_COMMAND, - "timeout_ms": 1_000, + "cmd": COMPLEX_FORCED_RM_COMMAND, + "yield_time_ms": 1_000, }); mount_sse_once( &server, sse(vec![ ev_response_created("resp-forced-rm-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-forced-rm-1"), ]), ) @@ -310,15 +310,15 @@ async fn granular_complex_forced_rm_requests_approval_when_allowed() -> Result<( let test = builder.build_with_auto_env(&server).await?; let call_id = "forced-rm-approval"; let args = json!({ - "command": COMPLEX_FORCED_RM_COMMAND, - "timeout_ms": 1_000, + "cmd": COMPLEX_FORCED_RM_COMMAND, + "yield_time_ms": 1_000, }); mount_sse_once( &server, sse(vec![ ev_response_created("resp-forced-rm-approval-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-forced-rm-approval-1"), ]), ) @@ -389,15 +389,15 @@ async fn deeply_nested_forced_rm_is_rejected_before_execution_when_approvals_are fs::write(&sentinel, "must not be deleted")?; let call_id = "deeply-nested-forced-rm"; let args = json!({ - "command": "env env env env env env env env env rm -rf forced-rm-sentinel", - "timeout_ms": 1_000, + "cmd": "env env env env env env env env env rm -rf forced-rm-sentinel", + "yield_time_ms": 1_000, }); mount_sse_once( &server, sse(vec![ ev_response_created("resp-deeply-nested-forced-rm-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-deeply-nested-forced-rm-1"), ]), ) @@ -532,20 +532,20 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { let call_id = "shell-forbidden"; let args = json!({ - "command": "echo blocked", - "timeout_ms": 1_000, + "cmd": "echo blocked", + "yield_time_ms": 1_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_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) .await; - mount_sse_once( + let results_mock = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-1", "done"), @@ -581,23 +581,18 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { ) .await?; - let EventMsg::ExecCommandEnd(end) = wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::ExecCommandEnd(_)) - }) - .await - else { - unreachable!() - }; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) }) .await; + let output = results_mock + .single_request() + .function_call_output_text(call_id) + .expect("forbidden command should produce a tool response"); assert!( - end.aggregated_output - .contains("policy forbids commands starting with `echo`"), - "unexpected output: {}", - end.aggregated_output + output.contains("policy forbids commands starting with `echo`"), + "unexpected output: {output}" ); Ok(()) @@ -908,61 +903,6 @@ async fn environment_command_policy_changes_invalidate_session_approvals() -> Re Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_empty_script_with_collaboration_mode_does_not_panic() -> Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| { - config - .features - .enable(Feature::CollaborationModes) - .expect("test config should allow feature update"); - }); - let test = builder.build(&server).await?; - let call_id = "shell-empty-script-collab"; - let args = json!({ - "command": "", - "timeout_ms": 1_000, - }); - - mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-empty-shell-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), - ev_completed("resp-empty-shell-1"), - ]), - ) - .await; - let results_mock = mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-empty-shell-1", "done"), - ev_completed("resp-empty-shell-2"), - ]), - ) - .await; - - let collaboration_mode = collaboration_mode_for_model(test.session_configured.model.clone()); - submit_user_turn( - &test, - "run an empty shell command", - AskForApproval::OnRequest, - PermissionProfile::Disabled, - Some(collaboration_mode), - ) - .await?; - - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - - let output_item = results_mock.single_request().function_call_output(call_id); - assert_no_matched_rules_invariant(&output_item); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_empty_script_with_collaboration_mode_does_not_panic() -> Result<()> { let server = start_mock_server().await; @@ -1022,61 +962,6 @@ async fn unified_exec_empty_script_with_collaboration_mode_does_not_panic() -> R Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_whitespace_script_with_collaboration_mode_does_not_panic() -> Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| { - config - .features - .enable(Feature::CollaborationModes) - .expect("test config should allow feature update"); - }); - let test = builder.build(&server).await?; - let call_id = "shell-whitespace-script-collab"; - let args = json!({ - "command": " \n\t ", - "timeout_ms": 1_000, - }); - - mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-whitespace-shell-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), - ev_completed("resp-whitespace-shell-1"), - ]), - ) - .await; - let results_mock = mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-whitespace-shell-1", "done"), - ev_completed("resp-whitespace-shell-2"), - ]), - ) - .await; - - let collaboration_mode = collaboration_mode_for_model(test.session_configured.model.clone()); - submit_user_turn( - &test, - "run whitespace shell command", - AskForApproval::OnRequest, - PermissionProfile::Disabled, - Some(collaboration_mode), - ) - .await?; - - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - - let output_item = results_mock.single_request().function_call_output(call_id); - assert_no_matched_rules_invariant(&output_item); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_whitespace_script_with_collaboration_mode_does_not_panic() -> Result<()> { let server = start_mock_server().await; diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index c6af1bc336..1cfc7bc228 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -1611,9 +1611,9 @@ async fn async_hook_context_is_injected_into_the_active_turn() -> Result<()> { let server = start_mock_server().await; let gate = TempDir::new()?; let release_path = gate.path().join("release"); - let call_id = "async-hook-context-gated-shell-command"; + let call_id = "async-hook-context-gated-exec-command"; let args = serde_json::json!({ - "command": format!( + "cmd": format!( r#"python3 -c 'import time; from pathlib import Path; gate = Path(r"{}"); exec("while not gate.exists(): time.sleep(0.01)")'"#, release_path.display() ) @@ -1623,7 +1623,7 @@ async fn async_hook_context_is_injected_into_the_active_turn() -> Result<()> { vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -1946,9 +1946,9 @@ async fn pre_tool_use_hook_spills_large_additional_context() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let call_id = "pretooluse-shell-command-large-context"; + let call_id = "pretooluse-exec-command-large-context"; let command = "printf pre-tool-output".to_string(); - let args = serde_json::json!({ "command": command }); + let args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ @@ -1956,7 +1956,7 @@ async fn pre_tool_use_hook_spills_large_additional_context() -> Result<()> { ev_response_created("resp-1"), core_test_support::responses::ev_function_call( call_id, - "shell_command", + "exec_command", &serde_json::to_string(&args)?, ), ev_completed("resp-1"), @@ -2763,14 +2763,14 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu } #[tokio::test] -async fn permission_request_hook_allows_shell_command_without_user_approval() -> Result<()> { +async fn permission_request_hook_allows_exec_command_without_user_approval() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let call_id = "permissionrequest-shell-command"; - let marker = std::env::temp_dir().join("permissionrequest-shell-command-marker"); + let call_id = "permissionrequest-exec-command"; + let marker = std::env::temp_dir().join("permissionrequest-exec-command-marker"); let command = format!("rm -f {}", marker.display()); - let args = serde_json::json!({ "command": command }); + let args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ @@ -2778,7 +2778,7 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() -> ev_response_created("resp-1"), core_test_support::responses::ev_function_call( call_id, - "shell_command", + "exec_command", &serde_json::to_string(&args)?, ), ev_completed("resp-1"), @@ -2845,8 +2845,8 @@ async fn permission_request_hook_allow_bypasses_strict_auto_review() -> Result<( let server = start_mock_server().await; let permission_call_id = "strict-hook-permissions"; - let command_call_id = "strict-hook-shell-command"; - let marker_name = "strict-hook-shell-command-marker"; + let command_call_id = "strict-hook-exec-command"; + let marker_name = "strict-hook-exec-command-marker"; let command = match test_target_os() { TestTargetOs::Linux | TestTargetOs::MacOs => format!("rm -f {marker_name}"), TestTargetOs::Windows => { @@ -2863,7 +2863,7 @@ async fn permission_request_hook_allow_bypasses_strict_auto_review() -> Result<( "reason": "Enable strict auto review", "permissions": requested_permissions, }); - let command_args = serde_json::json!({ "command": command }); + let command_args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ @@ -2880,7 +2880,7 @@ async fn permission_request_hook_allow_bypasses_strict_auto_review() -> Result<( ev_response_created("resp-strict-hook-2"), ev_function_call( command_call_id, - "shell_command", + "exec_command", &serde_json::to_string(&command_args)?, ), ev_completed("resp-strict-hook-2"), @@ -3183,13 +3183,13 @@ mode = "limited" allow_local_binding = true "#, )?; - let args = serde_json::json!({ "command": command }); + let args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-network-hook-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-network-hook-1"), ]), sse(vec![ @@ -3297,15 +3297,15 @@ allow_local_binding = true } #[tokio::test] -async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { +async fn pre_tool_use_json_deny_blocks_exec_command_before_execution() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let call_id = "pretooluse-shell-command"; + let call_id = "pretooluse-exec-command"; let marker_dir = TempDir::new()?; - let marker = marker_dir.path().join("pretooluse-shell-command-marker"); + let marker = marker_dir.path().join("pretooluse-exec-command-marker"); let command = format!("git init --quiet {}", marker.display()); - let args = serde_json::json!({ "command": command }); + let args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ @@ -3313,7 +3313,7 @@ async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { ev_response_created("resp-1"), core_test_support::responses::ev_function_call( call_id, - "shell_command", + "exec_command", &serde_json::to_string(&args)?, ), ev_completed("resp-1"), @@ -3388,13 +3388,13 @@ async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { } #[tokio::test] -async fn pre_tool_use_records_additional_context_for_shell_command() -> Result<()> { +async fn pre_tool_use_records_additional_context_for_exec_command() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let call_id = "pretooluse-shell-command-context"; + let call_id = "pretooluse-exec-command-context"; let command = "printf pre-tool-output".to_string(); - let args = serde_json::json!({ "command": command }); + let args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ @@ -3402,7 +3402,7 @@ async fn pre_tool_use_records_additional_context_for_shell_command() -> Result<( ev_response_created("resp-1"), core_test_support::responses::ev_function_call( call_id, - "shell_command", + "exec_command", &serde_json::to_string(&args)?, ), ev_completed("resp-1"), @@ -3450,14 +3450,14 @@ async fn pre_tool_use_records_additional_context_for_shell_command() -> Result<( } #[tokio::test] -async fn blocked_pre_tool_use_records_additional_context_for_shell_command() -> Result<()> { +async fn blocked_pre_tool_use_records_additional_context_for_exec_command() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let call_id = "pretooluse-shell-command-blocked-context"; - let marker = std::env::temp_dir().join("pretooluse-shell-command-blocked-context-marker"); + let call_id = "pretooluse-exec-command-blocked-context"; + let marker = std::env::temp_dir().join("pretooluse-exec-command-blocked-context-marker"); let command = format!("printf blocked > {}", marker.display()); - let args = serde_json::json!({ "command": command }); + let args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ @@ -3465,7 +3465,7 @@ async fn blocked_pre_tool_use_records_additional_context_for_shell_command() -> ev_response_created("resp-1"), core_test_support::responses::ev_function_call( call_id, - "shell_command", + "exec_command", &serde_json::to_string(&args)?, ), ev_completed("resp-1"), @@ -3534,19 +3534,19 @@ async fn async_pre_tool_use_cannot_block_or_rewrite_and_still_records_additional let hook_finished_path_for_fixture = hook_finished_path.clone(); let original_marker = gate.path().join("original"); let rewritten_marker = gate.path().join("rewritten"); - let call_id = "async-pretooluse-shell-command"; + let call_id = "async-pretooluse-exec-command"; let original_command = format!( r#"python3 -c 'import time; from pathlib import Path; gate = Path(r"{}"); exec("while not gate.exists(): time.sleep(0.01)"); Path(r"{}").write_text("original"); print("original-output")'"#, release_path.display(), original_marker.display() ); - let args = serde_json::json!({ "command": original_command }); + let args = serde_json::json!({ "cmd": original_command }); let responses = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -3687,14 +3687,12 @@ Path(r"{hook_finished_path}").write_text("finished", encoding="utf-8") #[derive(Clone, Copy)] enum BashRewriteSurface { ExecCommand, - ShellCommand, } impl BashRewriteSurface { fn slug(self) -> &'static str { match self { BashRewriteSurface::ExecCommand => "exec-command", - BashRewriteSurface::ShellCommand => "shell-command", } } @@ -3705,17 +3703,12 @@ impl BashRewriteSurface { "exec_command", &serde_json::to_string(&serde_json::json!({ "cmd": command_text }))?, )), - BashRewriteSurface::ShellCommand => Ok(ev_function_call( - call_id, - "shell_command", - &serde_json::to_string(&serde_json::json!({ "command": command_text }))?, - )), } } fn original_command(self, marker: &Path) -> String { match self { - BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => { + BashRewriteSurface::ExecCommand => { format!("git init --quiet {}", marker.display()) } } @@ -3723,7 +3716,7 @@ impl BashRewriteSurface { fn rewritten_command(self, marker: &Path) -> String { match self { - BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => { + BashRewriteSurface::ExecCommand => { format!("git init {}", marker.display()) } } @@ -3803,11 +3796,6 @@ async fn assert_pre_tool_use_rewrites_bash_surface(surface: BashRewriteSurface) Ok(()) } -#[tokio::test] -async fn pre_tool_use_rewrites_shell_command_before_execution() -> Result<()> { - assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::ShellCommand).await -} - #[tokio::test] async fn pre_tool_use_rewrites_exec_command_before_execution() -> Result<()> { assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::ExecCommand).await @@ -4074,14 +4062,14 @@ async fn post_tool_use_exit_two_rejects_code_mode_tool_promise() -> Result<()> { } #[tokio::test] -async fn plugin_pre_tool_use_blocks_shell_command_before_execution() -> Result<()> { +async fn plugin_pre_tool_use_blocks_exec_command_before_execution() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let call_id = "plugin-pretooluse-shell-command"; - let marker = std::env::temp_dir().join("plugin-pretooluse-shell-command-marker"); + let call_id = "plugin-pretooluse-exec-command"; + let marker = std::env::temp_dir().join("plugin-pretooluse-exec-command-marker"); let command = format!("printf blocked > {}", marker.display()); - let args = serde_json::json!({ "command": command }); + let args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ @@ -4089,7 +4077,7 @@ async fn plugin_pre_tool_use_blocks_shell_command_before_execution() -> Result<( ev_response_created("resp-1"), core_test_support::responses::ev_function_call( call_id, - "shell_command", + "exec_command", &serde_json::to_string(&args)?, ), ev_completed("resp-1"), @@ -4232,7 +4220,7 @@ async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> { let call_id = "pretooluse-config-toml"; let marker = std::env::temp_dir().join("pretooluse-config-toml-marker"); let command = format!("printf blocked > {}", marker.display()); - let args = serde_json::json!({ "command": command }); + let args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ @@ -4240,7 +4228,7 @@ async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> { ev_response_created("resp-1"), core_test_support::responses::ev_function_call( call_id, - "shell_command", + "exec_command", &serde_json::to_string(&args)?, ), ev_completed("resp-1"), @@ -4315,7 +4303,7 @@ async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> { let server = start_mock_server().await; let call_id = "pretooluse-merged-sources"; let command = "printf merged-hooks".to_string(); - let args = serde_json::json!({ "command": command }); + let args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ @@ -4323,7 +4311,7 @@ async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> { ev_response_created("resp-1"), core_test_support::responses::ev_function_call( call_id, - "shell_command", + "exec_command", &serde_json::to_string(&args)?, ), ev_completed("resp-1"), @@ -4824,13 +4812,13 @@ async fn pre_tool_use_rewrites_local_function_tool_before_execution() -> Result< } #[tokio::test] -async fn post_tool_use_records_additional_context_for_shell_command() -> Result<()> { +async fn post_tool_use_records_additional_context_for_exec_command() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let call_id = "posttooluse-shell-command"; + let call_id = "posttooluse-exec-command"; let command = "printf post-tool-output".to_string(); - let args = serde_json::json!({ "command": command }); + let args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ @@ -4838,7 +4826,7 @@ async fn post_tool_use_records_additional_context_for_shell_command() -> Result< ev_response_created("resp-1"), core_test_support::responses::ev_function_call( call_id, - "shell_command", + "exec_command", &serde_json::to_string(&args)?, ), ev_completed("resp-1"), @@ -4913,13 +4901,13 @@ async fn post_tool_use_records_additional_context_for_shell_command() -> Result< } #[tokio::test] -async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason() -> Result<()> { +async fn post_tool_use_block_decision_replaces_exec_command_output_with_reason() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let call_id = "posttooluse-shell-command-block"; + let call_id = "posttooluse-exec-command-block"; let command = "printf blocked-output".to_string(); - let args = serde_json::json!({ "command": command }); + let args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ @@ -4927,7 +4915,7 @@ async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason( ev_response_created("resp-1"), core_test_support::responses::ev_function_call( call_id, - "shell_command", + "exec_command", &serde_json::to_string(&args)?, ), ev_completed("resp-1"), @@ -4973,14 +4961,14 @@ async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason( } #[tokio::test] -async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_reason() -> Result<()> +async fn post_tool_use_continue_false_replaces_exec_command_output_with_stop_reason() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let call_id = "posttooluse-shell-command-stop"; + let call_id = "posttooluse-exec-command-stop"; let command = "printf stop-output".to_string(); - let args = serde_json::json!({ "command": command }); + let args = serde_json::json!({ "cmd": command }); let responses = mount_sse_sequence( &server, vec![ @@ -4988,7 +4976,7 @@ async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_re ev_response_created("resp-1"), core_test_support::responses::ev_function_call( call_id, - "shell_command", + "exec_command", &serde_json::to_string(&args)?, ), ev_completed("resp-1"), diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 98032d2087..7d45644061 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -40,6 +40,7 @@ mod agent_execution; mod agent_websocket; mod agents_md; mod apply_patch_cli; +mod apply_patch_serialization; #[cfg(not(target_os = "windows"))] mod approvals; mod audio_truncation; @@ -134,8 +135,6 @@ mod safety_buffering; mod safety_check_downgrade; mod search_tool; mod send_user_message_async; -mod shell_command; -mod shell_serialization; mod shell_snapshot; mod skill_approval; mod skills; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 699696289d..743fe4d181 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -115,7 +115,7 @@ fn test_model_info( effort: ReasoningEffort::Medium, description: ReasoningEffort::Medium.to_string(), }], - shell_type: ConfigShellToolType::ShellCommand, + shell_type: ConfigShellToolType::UnifiedExec, visibility: ModelVisibility::List, supported_in_api: true, input_modalities, @@ -1330,7 +1330,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< effort: ReasoningEffort::Medium, description: ReasoningEffort::Medium.to_string(), }], - shell_type: ConfigShellToolType::ShellCommand, + shell_type: ConfigShellToolType::UnifiedExec, visibility: ModelVisibility::List, supported_in_api: true, input_modalities: default_input_modalities(), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index b618acb3e7..7dd00b8122 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -495,7 +495,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { description: "medium".to_string(), }, ], - shell_type: ConfigShellToolType::ShellCommand, + shell_type: ConfigShellToolType::UnifiedExec, visibility: ModelVisibility::List, supported_in_api: true, priority, diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index f3d559cea0..ffc69e98e1 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -21,8 +21,8 @@ use core_test_support::TempDirExt; 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_exec_command_call; use core_test_support::responses::ev_response_created; -use core_test_support::responses::ev_shell_command_call; use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::skip_if_no_network; @@ -38,7 +38,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch const ETAG_1: &str = "\"models-etag-1\""; const ETAG_2: &str = "\"models-etag-2\""; - const CALL_ID: &str = "shell-command-call-1"; + const CALL_ID: &str = "exec-command-call-1"; let server = MockServer::start().await; @@ -87,7 +87,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch // It also includes a mismatched X-Models-Etag, which should trigger a /models refresh. let first_response_body = sse(vec![ ev_response_created("resp-1"), - ev_shell_command_call(CALL_ID, "/bin/echo 'etag ok'"), + ev_exec_command_call(CALL_ID, "/bin/echo 'etag ok'"), ev_completed("resp-1"), ]); responses::mount_response_once( diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 6fd4c4360e..b3489240d1 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -80,9 +80,9 @@ fn assert_empty_mcp_tool_fields(line: &str) -> Result<(), String> { Ok(()) } -fn shell_command_call(call_id: &str, command: &str) -> serde_json::Value { - let args = serde_json::json!({ "command": command }).to_string(); - ev_function_call(call_id, "shell_command", &args) +fn exec_command_call(call_id: &str, command: &str) -> serde_json::Value { + let args = serde_json::json!({ "cmd": command }).to_string(); + ev_function_call(call_id, "exec_command", &args) } fn touch_command(path: &str) -> String { @@ -989,13 +989,13 @@ async fn handle_response_item_records_tool_result_for_function_call() { #[tokio::test] #[traced_test] -async fn handle_response_item_records_tool_result_for_shell_command_call() { +async fn handle_response_item_records_tool_result_for_exec_command_call() { let server = start_mock_server().await; mount_sse_once( &server, sse(vec![ - shell_command_call("shell-call", "echo shell"), + exec_command_call("shell-call", "echo shell"), ev_completed("done"), ]), ) @@ -1038,10 +1038,10 @@ async fn handle_response_item_records_tool_result_for_shell_command_call() { .find(|line| line.contains("codex.tool_result") && line.contains("call_id=shell-call")) .ok_or_else(|| "missing codex.tool_result event".to_string())?; - if !line.contains("tool_name=shell_command") { + if !line.contains("tool_name=exec_command") { return Err("missing tool_name field".to_string()); } - if !line.contains("arguments={\"command\":\"echo shell\"}") { + if !line.contains("arguments={\"cmd\":\"echo shell\"}") { return Err("missing arguments field".to_string()); } let output_idx = line @@ -1077,8 +1077,8 @@ fn tool_decision_assertion<'a>( .ok_or_else(|| format!("missing codex.tool_decision event for {call_id}"))?; let lower = line.to_lowercase(); - if !lower.contains("tool_name=shell_command") { - return Err("missing tool_name for shell_command".to_string()); + if !lower.contains("tool_name=exec_command") { + return Err("missing tool_name for exec_command".to_string()); } if !lower.contains(&format!("decision={expected_decision}")) { return Err(format!("unexpected decision for {call_id}")); @@ -1108,8 +1108,8 @@ fn sandbox_outcome_assertion<'a>( .ok_or_else(|| format!("missing codex.sandbox_outcome event for {call_id}"))?; let lower = line.to_lowercase(); - if !lower.contains("tool_name=shell_command") { - return Err("missing tool_name for shell_command".to_string()); + if !lower.contains("tool_name=exec_command") { + return Err("missing tool_name for exec_command".to_string()); } if !lower.contains(&format!("outcome={expected_outcome}")) { return Err(format!("unexpected sandbox outcome for {call_id}")); @@ -1209,7 +1209,7 @@ fn sandbox_outcome_event_records_outcome() { ); telemetry.sandbox_outcome( - "shell_command", + "exec_command", "sandbox-outcome-call", "escalated", Duration::from_millis(/*millis*/ 12), @@ -1224,12 +1224,12 @@ fn sandbox_outcome_event_records_outcome() { #[tokio::test] #[traced_test] -async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { +async fn handle_exec_command_autoapprove_from_config_records_tool_decision() { let server = start_mock_server().await; mount_sse_once( &server, sse(vec![ - shell_command_call("auto_config_call", "echo local shell"), + exec_command_call("auto_config_call", "echo local shell"), ev_completed("done"), ]), ) @@ -1275,13 +1275,13 @@ async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { #[tokio::test] #[traced_test] -async fn handle_shell_command_user_approved_records_tool_decision() { +async fn handle_exec_command_user_approved_records_tool_decision() { let server = start_mock_server().await; let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - shell_command_call("user_approved_call", &command), + exec_command_call("user_approved_call", &command), ev_completed("done"), ]), ) @@ -1339,14 +1339,14 @@ async fn handle_shell_command_user_approved_records_tool_decision() { #[tokio::test] #[traced_test] -async fn handle_shell_command_user_approved_for_session_records_tool_decision() { +async fn handle_exec_command_user_approved_for_session_records_tool_decision() { let server = start_mock_server().await; let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - shell_command_call("user_approved_session_call", &command), + exec_command_call("user_approved_session_call", &command), ev_completed("done"), ]), ) @@ -1410,7 +1410,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { mount_sse_once( &server, sse(vec![ - shell_command_call("sandbox_retry_call", &command), + exec_command_call("sandbox_retry_call", &command), ev_completed("done"), ]), ) @@ -1467,14 +1467,14 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { #[tokio::test] #[traced_test] -async fn handle_shell_command_user_denies_records_tool_decision() { +async fn handle_exec_command_user_denies_records_tool_decision() { let server = start_mock_server().await; let command = touch_command("codex-otel-approval-test"); mount_sse_once( &server, sse(vec![ - shell_command_call("user_denied_call", &command), + exec_command_call("user_denied_call", &command), ev_completed("done"), ]), ) @@ -1538,7 +1538,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() mount_sse_once( &server, sse(vec![ - shell_command_call("sandbox_session_call", &command), + exec_command_call("sandbox_session_call", &command), ev_completed("done"), ]), ) @@ -1602,7 +1602,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { mount_sse_once( &server, sse(vec![ - shell_command_call("sandbox_deny_call", &command), + exec_command_call("sandbox_deny_call", &command), ev_completed("done"), ]), ) diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 909829abbf..a26638430e 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -1263,9 +1263,9 @@ async fn steered_user_input_waits_when_tool_output_triggers_compact_before_next_ "printf '%04000d' 0" }; let large_output_args = json!({ - "command": large_output_command, + "cmd": large_output_command, "login": false, - "timeout_ms": 2000, + "yield_time_ms": 2000, }) .to_string(); @@ -1273,7 +1273,7 @@ async fn steered_user_input_waits_when_tool_output_triggers_compact_before_next_ chunk(ev_response_created("resp-1")), chunk(ev_function_call( "call-1", - "shell_command", + "exec_command", &large_output_args, )), gated_chunk( diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 7f16f7e064..e41326239f 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -388,7 +388,7 @@ async fn persisted_remote_plugin_command_attribution_flows_through_turn_context( let command = shlex::try_join(["/bin/sh", script_path.to_string_lossy().as_ref()])?; let call_id = "remote-plugin-command"; let arguments = serde_json::to_string(&serde_json::json!({ - "command": command, + "cmd": command, "login": false, }))?; mount_sse_sequence( @@ -396,7 +396,7 @@ async fn persisted_remote_plugin_command_attribution_flows_through_turn_context( vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &arguments), + ev_function_call(call_id, "exec_command", &arguments), ev_completed("resp-1"), ]), sse(vec![ @@ -1450,7 +1450,7 @@ async fn implicit_plugin_skill_invocation_tracks_remote_plugin_id( } }; let command_args = serde_json::json!({ - "command": command, + "cmd": command, "login": false, }) .to_string(); @@ -1459,7 +1459,7 @@ async fn implicit_plugin_skill_invocation_tracks_remote_plugin_id( vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call("call-1", "shell_command", &command_args), + ev_function_call("call-1", "exec_command", &command_args), ev_completed("resp-1"), ]), sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 582de2f9d4..959abfeedb 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -347,7 +347,7 @@ async fn remote_test_env_exposes_target_shell_to_model() -> Result<()> { let mut builder = test_codex().with_config(|config| { config .features - .disable(Feature::UnifiedExec) + .disable(Feature::ShellTool) .expect("test config should allow feature update"); }); let test = builder.build_with_auto_env(&server).await?; @@ -356,7 +356,7 @@ async fn remote_test_env_exposes_target_shell_to_model() -> Result<()> { let request = response_mock.single_request(); let tools = tool_names(&request.body_json()); - assert!(!tools.contains(&"shell_command".to_string())); + assert!(!tools.contains(&"exec_command".to_string())); let environment_context = request .message_input_texts("user") .into_iter() @@ -2793,7 +2793,6 @@ async fn exec_command_routing_output( let tools = tool_names(&request.body_json()); assert!(tools.contains(&"exec_command".to_string())); assert!(tools.contains(&"write_stdin".to_string())); - assert!(!tools.contains(&"shell_command".to_string())); Ok(output) } diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 69a62cf5c7..b1c77fe359 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -76,10 +76,20 @@ async fn unknown_model_sends_builtin_instructions() -> Result<()> { test.submit_turn("use fallback model metadata").await?; - assert_eq!( - response.single_request().instructions_text(), - BASE_INSTRUCTIONS - ); + let request = response.single_request(); + assert_eq!(request.instructions_text(), BASE_INSTRUCTIONS); + let body = request.body_json(); + let tools = body["tools"] + .as_array() + .expect("fallback model tools should be present"); + for tool_name in ["exec_command", "write_stdin"] { + assert!( + tools + .iter() + .any(|tool| tool["name"].as_str() == Some(tool_name)), + "fallback model should expose {tool_name}: {tools:?}" + ); + } Ok(()) } @@ -534,13 +544,17 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { experimental_supported_tools: Vec::new(), }; - let models_mock = mount_models_once( - &server, - ModelsResponse { - models: vec![remote_model], - }, - ) - .await; + let mut models_response = serde_json::to_value(ModelsResponse { + models: vec![remote_model], + })?; + models_response["models"][0]["shell_type"] = json!("shell_command"); + Mock::given(method("GET")) + .and(path("/v1/models")) + .respond_with(ResponseTemplate::new(200).set_body_json(models_response)) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; let mut builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) @@ -560,14 +574,6 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { assert_eq!(available_model.model, REMOTE_MODEL_SLUG); - let requests = models_mock.requests(); - assert_eq!( - requests.len(), - 1, - "expected a single /models refresh request for the remote models feature" - ); - assert_eq!(requests[0].url.path(), "/v1/models"); - let model_info = models_manager .get_model_info(REMOTE_MODEL_SLUG, &config.to_models_manager_config()) .await; @@ -599,7 +605,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { ev_completed("resp-2"), ]), ]; - mount_sse_sequence(&server, responses).await; + let response_mock = mount_sse_sequence(&server, responses).await; let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = @@ -631,6 +637,24 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + let request = response_mock + .requests() + .into_iter() + .next() + .expect("remote model should receive an inference request"); + let body = request.body_json(); + let tools = body["tools"] + .as_array() + .expect("remote model tools should be present"); + for tool_name in ["exec_command", "write_stdin"] { + assert!( + tools + .iter() + .any(|tool| tool["name"].as_str() == Some(tool_name)), + "legacy remote model metadata should expose {tool_name}: {tools:?}" + ); + } + Ok(()) } @@ -749,7 +773,7 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> { effort: ReasoningEffort::Medium, description: ReasoningEffort::Medium.to_string(), }], - shell_type: ConfigShellToolType::ShellCommand, + shell_type: ConfigShellToolType::UnifiedExec, visibility: ModelVisibility::List, supported_in_api: true, input_modalities: default_input_modalities(), @@ -1331,7 +1355,7 @@ fn test_remote_model_with_policy( effort: ReasoningEffort::Medium, description: ReasoningEffort::Medium.to_string(), }], - shell_type: ConfigShellToolType::ShellCommand, + shell_type: ConfigShellToolType::UnifiedExec, visibility, supported_in_api: true, input_modalities: default_input_modalities(), diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index befb16c22f..cced118981 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -42,7 +42,6 @@ use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; -use core_test_support::skip_if_remote; use core_test_support::skip_if_sandbox; use core_test_support::skip_if_target_windows; use core_test_support::skip_if_wine_exec; @@ -109,19 +108,19 @@ fn parse_result(item: &Value) -> CommandResult { } } -fn shell_event_with_request_permissions( +fn command_event_with_request_permissions( call_id: &str, command: &str, additional_permissions: &S, ) -> Result { let args = json!({ - "command": command, - "timeout_ms": 1_000_u64, + "cmd": command, + "yield_time_ms": 10_000_u64, "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, "additional_permissions": additional_permissions, }); let args_str = serde_json::to_string(&args)?; - Ok(ev_function_call(call_id, "shell_command", &args_str)) + Ok(ev_function_call(call_id, "exec_command", &args_str)) } fn request_permissions_tool_event( @@ -137,13 +136,13 @@ fn request_permissions_tool_event( Ok(ev_function_call(call_id, "request_permissions", &args_str)) } -fn shell_command_event(call_id: &str, command: &str) -> Result { +fn command_event(call_id: &str, command: &str) -> Result { let args = json!({ - "command": command, - "timeout_ms": 1_000_u64, + "cmd": command, + "yield_time_ms": 10_000_u64, }); let args_str = serde_json::to_string(&args)?; - Ok(ev_function_call(call_id, "shell_command", &args_str)) + Ok(ev_function_call(call_id, "exec_command", &args_str)) } fn exec_command_event(call_id: &str, command: &str) -> Result { @@ -363,7 +362,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res )), ..Default::default() }; - let event = shell_event_with_request_permissions(call_id, command, &requested_permissions)?; + let event = command_event_with_request_permissions(call_id, command, &requested_permissions)?; let _ = mount_sse_once( &server, @@ -760,18 +759,8 @@ async fn interrupted_request_permissions_auto_review_aborts_guardian_review() -> Ok(()) } -#[derive(Clone, Copy, Debug)] -enum AdditionalPermissionsCommandTool { - ShellCommand, - ExecCommand, -} - -#[test_case(AdditionalPermissionsCommandTool::ShellCommand ; "shell_command")] -#[test_case(AdditionalPermissionsCommandTool::ExecCommand ; "exec_command")] #[tokio::test(flavor = "current_thread")] -async fn relative_additional_permissions_resolve_against_tool_workdir( - command_tool: AdditionalPermissionsCommandTool, -) -> Result<()> { +async fn relative_additional_permissions_resolve_against_tool_workdir() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -818,26 +807,13 @@ async fn relative_additional_permissions_resolve_against_tool_workdir( )), ..Default::default() }; - let (tool_name, arguments) = match command_tool { - AdditionalPermissionsCommandTool::ShellCommand => ( - "shell_command", - json!({ - "command": command, - "workdir": workdir, - "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, - "additional_permissions": additional_permissions, - }), - ), - AdditionalPermissionsCommandTool::ExecCommand => ( - "exec_command", - json!({ - "cmd": command, - "workdir": workdir, - "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, - "additional_permissions": additional_permissions, - }), - ), - }; + let tool_name = "exec_command"; + let arguments = json!({ + "cmd": command, + "workdir": workdir, + "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, + "additional_permissions": additional_permissions, + }); let event = ev_function_call(call_id, tool_name, &serde_json::to_string(&arguments)?); let _ = mount_sse_once( @@ -935,7 +911,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd )), ..Default::default() }; - let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; + let event = command_event_with_request_permissions(call_id, &command, &requested_permissions)?; let _ = mount_sse_once( &server, @@ -1039,7 +1015,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp )), ..Default::default() }; - let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; + let event = command_event_with_request_permissions(call_id, &command, &requested_permissions)?; let _ = mount_sse_once( &server, @@ -1150,7 +1126,7 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() -> )), ..RequestPermissionProfile::default() }; - let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; + let event = command_event_with_request_permissions(call_id, &command, &requested_permissions)?; let _ = mount_sse_once( &server, @@ -1255,7 +1231,7 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul )), ..Default::default() }; - let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?; + let event = command_event_with_request_permissions(call_id, &command, &requested_permissions)?; let _ = mount_sse_once( &server, @@ -1560,121 +1536,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req } #[tokio::test(flavor = "current_thread")] -async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Result<()> { - skip_if_no_network!(Ok(())); - skip_if_sandbox!(Ok(())); - - let server = start_mock_server().await; - let approval_policy = AskForApproval::OnRequest; - let permission_profile = workspace_write_excluding_tmp(); - let permission_profile_for_config = workspace_write_excluding_tmp(); - - let mut builder = test_codex().with_config(move |config| { - config.permissions.approval_policy = Constrained::allow_any(approval_policy); - config - .permissions - .set_permission_profile(permission_profile_for_config) - .expect("set permission profile"); - config - .features - .enable(Feature::ExecPermissionApprovals) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::RequestPermissionsTool) - .expect("test config should allow feature update"); - }); - let test = builder.build(&server).await?; - - let outside_dir = tempfile::tempdir()?; - let outside_write = outside_dir.path().join("sticky-shell-write.txt"); - let command = format!( - "printf {:?} > {:?} && cat {:?}", - "sticky-shell-grant-ok", outside_write, outside_write - ); - let requested_permissions = requested_directory_write_permissions(outside_dir.path()); - let normalized_requested_permissions = - normalized_directory_write_permissions(outside_dir.path())?; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-sticky-shell-1"), - request_permissions_tool_event( - "permissions-call", - "Allow writing outside the workspace", - &requested_permissions, - )?, - ev_completed("resp-sticky-shell-1"), - ]), - sse(vec![ - ev_response_created("resp-sticky-shell-2"), - shell_command_event("shell-call", &command)?, - ev_completed("resp-sticky-shell-2"), - ]), - sse(vec![ - ev_response_created("resp-sticky-shell-3"), - ev_assistant_message("msg-sticky-shell-1", "done"), - ev_completed("resp-sticky-shell-3"), - ]), - ], - ) - .await; - - submit_turn( - &test, - "write outside the workspace", - approval_policy, - permission_profile, - ) - .await?; - - let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; - assert_eq!( - granted_permissions, - normalized_requested_permissions.clone() - ); - test.codex - .submit(Op::RequestPermissionsResponse { - id: "permissions-call".to_string(), - response: RequestPermissionsResponse { - permissions: normalized_requested_permissions.clone(), - scope: PermissionGrantScope::Turn, - strict_auto_review: false, - }, - }) - .await?; - - if let Some(approval) = wait_for_exec_approval_or_completion(&test).await { - test.codex - .submit(Op::ExecApproval { - id: approval.effective_approval_id(), - turn_id: None, - decision: ReviewDecision::Approved, - }) - .await?; - wait_for_completion(&test).await; - } - - let shell_output = responses - .function_call_output_text("shell-call") - .map(|output| json!({ "output": output })) - .expect("expected shell-call output"); - let result = parse_result(&shell_output); - assert!( - result.exit_code.is_none_or(|exit_code| exit_code == 0), - "expected success output, got exit_code={:?}, stdout={:?}", - result.exit_code, - result.stdout - ); - assert_eq!(result.stdout.trim(), "sticky-shell-grant-ok"); - assert_eq!(fs::read_to_string(&outside_write)?, "sticky-shell-grant-ok"); - - Ok(()) -} - -#[tokio::test(flavor = "current_thread")] -async fn request_permissions_grants_apply_to_later_shell_command_calls_without_inline_permission_feature() +async fn request_permissions_grants_apply_to_later_exec_command_calls_without_inline_permission_feature() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -1722,7 +1584,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i ]), sse(vec![ ev_response_created("resp-sticky-shell-independent-2"), - shell_command_event("shell-call", &command)?, + command_event("shell-call", &command)?, ev_completed("resp-sticky-shell-independent-2"), ]), sse(vec![ @@ -2224,7 +2086,6 @@ const WRITE_CALL_ID: &str = "denied-child-permissions-write"; #[derive(Clone, Copy, Debug)] enum WriteTool { ExecCommand, - ShellCommand, ApplyPatch, } @@ -2237,8 +2098,6 @@ enum ApprovalMode { #[test_case(WriteTool::ExecCommand, PermissionGrantScope::Turn, ApprovalMode::Prompt; "exec_command_turn")] #[test_case(WriteTool::ExecCommand, PermissionGrantScope::Session, ApprovalMode::Prompt; "exec_command_session")] -#[test_case(WriteTool::ShellCommand, PermissionGrantScope::Turn, ApprovalMode::Prompt; "shell_command_turn")] -#[test_case(WriteTool::ShellCommand, PermissionGrantScope::Session, ApprovalMode::Prompt; "shell_command_session")] #[test_case(WriteTool::ApplyPatch, PermissionGrantScope::Turn, ApprovalMode::Prompt; "apply_patch_turn")] #[test_case(WriteTool::ApplyPatch, PermissionGrantScope::Session, ApprovalMode::Prompt; "apply_patch_session")] #[test_case(WriteTool::ExecCommand, PermissionGrantScope::Session, ApprovalMode::Never; "exec_command_session_never")] @@ -2255,13 +2114,6 @@ async fn denied_child_permissions_require_fresh_approval( Ok(()), "this regression exercises POSIX split-policy enforcement; a disabled Windows sandbox can independently prompt for the command" ); - if matches!(tool, WriteTool::ShellCommand) { - skip_if_remote!( - Ok(()), - "the legacy shell_command tool is only registered for a single local environment" - ); - } - let harness = TestCodexHarness::with_auto_env_builder(test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); @@ -2322,9 +2174,6 @@ async fn denied_child_permissions_require_fresh_approval( &command, &fresh_permissions, )?, - WriteTool::ShellCommand => { - shell_event_with_request_permissions(WRITE_CALL_ID, &command, &fresh_permissions)? - } WriteTool::ApplyPatch => ev_apply_patch_custom_tool_call( WRITE_CALL_ID, &format!( @@ -2425,10 +2274,7 @@ async fn denied_child_permissions_require_fresh_approval( } let (decision, reason) = match (tool, event) { - ( - WriteTool::ExecCommand | WriteTool::ShellCommand, - EventMsg::ExecApprovalRequest(approval), - ) => { + (WriteTool::ExecCommand, EventMsg::ExecApprovalRequest(approval)) => { assert_eq!(approval.call_id, WRITE_CALL_ID); let expected_permissions = PermissionProfile { file_system: Some(FileSystemPermissions { diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 609caa113e..eb1d21545b 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -207,15 +207,15 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul let server = start_mock_server().await; let tool_args = serde_json::json!({ - "command": "echo hello", - "timeout_ms": 1_000 + "cmd": "echo hello", + "yield_time_ms": 1_000 }); let first_response = sse_response(sse(vec![ ev_response_created("resp-1"), ev_function_call( "call-1", - "shell_command", + "exec_command", &serde_json::to_string(&tool_args)?, ), core_test_support::responses::ev_completed("resp-1"), @@ -364,15 +364,15 @@ async fn model_verification_only_emits_once_per_turn() -> Result<()> { let server = start_mock_server().await; let tool_args = serde_json::json!({ - "command": "echo hello", - "timeout_ms": 1_000 + "cmd": "echo hello", + "yield_time_ms": 1_000 }); let first_response = sse_response(sse(vec![ ev_response_created("resp-1"), ev_function_call( "call-1", - "shell_command", + "exec_command", &serde_json::to_string(&tool_args)?, ), ev_model_verification_metadata("resp-1", vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]), diff --git a/codex-rs/core/tests/suite/shell_command.rs b/codex-rs/core/tests/suite/shell_command.rs deleted file mode 100644 index 80519c6160..0000000000 --- a/codex-rs/core/tests/suite/shell_command.rs +++ /dev/null @@ -1,391 +0,0 @@ -use std::time::Duration; - -use anyhow::Result; -use codex_protocol::models::PermissionProfile; -use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR; -use core_test_support::TestTargetOs; -use core_test_support::assert_regex_match; -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_sequence; -use core_test_support::responses::sse; -use core_test_support::skip_if_host_windows; -use core_test_support::skip_if_no_network; -use core_test_support::skip_if_wine_exec; -use core_test_support::test_codex::TestCodexBuilder; -use core_test_support::test_codex::TestCodexHarness; -use core_test_support::test_codex::test_codex; -use core_test_support::test_target_os; -use pretty_assertions::assert_eq; -use serde_json::json; -use test_case::test_case; - -#[cfg(windows)] -const DEFAULT_SHELL_TIMEOUT_MS: i64 = 7_000; -#[cfg(not(windows))] -const DEFAULT_SHELL_TIMEOUT_MS: i64 = 2_000; - -#[cfg(windows)] -const MEDIUM_TIMEOUT: Duration = Duration::from_secs(10); -#[cfg(not(windows))] -const MEDIUM_TIMEOUT: Duration = Duration::from_secs(5); - -fn shell_responses_with_timeout( - call_id: &str, - command: &str, - login: Option, - timeout_ms: i64, -) -> Vec { - let args = json!({ - "command": command, - "timeout_ms": timeout_ms, - "login": login, - }); - - let arguments = serde_json::to_string(&args).expect("serialize shell command arguments"); - - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &arguments), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ] -} - -fn shell_responses(call_id: &str, command: &str, login: Option) -> Vec { - shell_responses_with_timeout(call_id, command, login, DEFAULT_SHELL_TIMEOUT_MS) -} - -async fn shell_command_harness_with( - configure: impl FnOnce(TestCodexBuilder) -> TestCodexBuilder, -) -> Result { - let builder = configure(test_codex()); - TestCodexHarness::with_builder(builder).await -} - -async fn mount_shell_responses( - harness: &TestCodexHarness, - call_id: &str, - command: &str, - login: Option, -) { - mount_sse_sequence(harness.server(), shell_responses(call_id, command, login)).await; -} - -async fn mount_shell_responses_with_timeout( - harness: &TestCodexHarness, - call_id: &str, - command: &str, - login: Option, - timeout: Duration, -) { - mount_sse_sequence( - harness.server(), - shell_responses_with_timeout(call_id, command, login, timeout.as_millis() as i64), - ) - .await; -} - -fn assert_shell_command_output(output: &str, expected: &str) -> Result<()> { - let normalized_output = output - .replace("\r\n", "\n") - .replace('\r', "\n") - .trim_end_matches('\n') - .to_string(); - - let expected_pattern = format!( - r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n{expected}\n?$" - ); - - assert_regex_match(&expected_pattern, &normalized_output); - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_works() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?; - - let call_id = "shell-command-call"; - mount_shell_responses( - &harness, - call_id, - "echo 'hello, world'", - /*login*/ None, - ) - .await; - harness.submit("run the echo command").await?; - - let output = harness.function_call_stdout(call_id).await; - assert_shell_command_output(&output, "hello, world")?; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_does_not_expose_configured_noise_auth_token() -> Result<()> { - skip_if_no_network!(Ok(())); - skip_if_wine_exec!(Ok(()), "shell_command is unavailable for Wine executors"); - - let builder = test_codex().with_model("gpt-5.4").with_config(|config| { - config.permissions.shell_environment_policy.r#set.insert( - CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_string(), - "configured-noise-token".to_string(), - ); - config.permissions.shell_environment_policy.r#set.insert( - CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_ascii_lowercase(), - "case-variant-noise-token".to_string(), - ); - }); - let harness = TestCodexHarness::with_auto_env_builder(builder).await?; - let command = match test_target_os() { - TestTargetOs::Linux | TestTargetOs::MacOs => { - "if [ -n \"${CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN:-}\" ] || [ -n \"${codex_exec_server_noise_auth_token:-}\" ]; then echo leaked; else echo unset; fi" - } - TestTargetOs::Windows => { - "if ($env:CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN) { Write-Output leaked } else { Write-Output unset }" - } - }; - let call_id = "shell-command-noise-auth-token"; - mount_shell_responses(&harness, call_id, command, /*login*/ None).await; - harness - .submit("check the remote execution auth token") - .await?; - - assert_shell_command_output(&harness.function_call_stdout(call_id).await, "unset")?; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_rejects_justification_without_sandbox_permissions() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?; - let call_id = "shell-command-missing-sandbox-permissions"; - let args = json!({ - "command": "echo should not run", - "justification": "Allow this command", - }); - mount_sse_sequence( - harness.server(), - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ], - ) - .await; - - harness - .submit_with_permission_profile( - "run the command with escalation", - PermissionProfile::Disabled, - ) - .await?; - - assert_eq!( - harness.function_call_stdout(call_id).await, - "`justification` requires an explicit `sandbox_permissions`; use `sandbox_permissions: \"require_escalated\"` for unsandboxed execution, or omit `justification`." - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn output_with_login() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?; - - let call_id = "shell-command-call-login-true"; - mount_shell_responses(&harness, call_id, "echo 'hello, world'", Some(true)).await; - harness.submit("run the echo command with login").await?; - - let output = harness.function_call_stdout(call_id).await; - assert_shell_command_output(&output, "hello, world")?; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn output_without_login() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?; - - let call_id = "shell-command-call-login-false"; - mount_shell_responses(&harness, call_id, "echo 'hello, world'", Some(false)).await; - harness.submit("run the echo command without login").await?; - - let output = harness.function_call_stdout(call_id).await; - assert_shell_command_output(&output, "hello, world")?; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn multi_line_output_with_login() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?; - - let call_id = "shell-command-call-first-extra-login"; - mount_shell_responses( - &harness, - call_id, - "echo 'first line\nsecond line'", - Some(true), - ) - .await; - harness.submit("run the command with login").await?; - - let output = harness.function_call_stdout(call_id).await; - assert_shell_command_output(&output, "first line\nsecond line")?; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn pipe_output_with_login() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - skip_if_host_windows!(Ok(())); - - let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?; - - let call_id = "shell-command-call-second-extra-no-login"; - mount_shell_responses( - &harness, - call_id, - "echo 'hello, world' | cat", - /*login*/ None, - ) - .await; - harness.submit("run the command without login").await?; - - let output = harness.function_call_stdout(call_id).await; - assert_shell_command_output(&output, "hello, world")?; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn pipe_output_without_login() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - skip_if_host_windows!(Ok(())); - - let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?; - - let call_id = "shell-command-call-third-extra-login-false"; - mount_shell_responses(&harness, call_id, "echo 'hello, world' | cat", Some(false)).await; - harness.submit("run the command without login").await?; - - let output = harness.function_call_stdout(call_id).await; - assert_shell_command_output(&output, "hello, world")?; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_times_out_with_timeout_ms() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?; - let call_id = "shell-command-timeout"; - let command = if cfg!(windows) { - "Start-Sleep -Seconds 5" - } else { - "sleep 5" - }; - mount_shell_responses_with_timeout( - &harness, - call_id, - command, - /*login*/ None, - Duration::from_millis(200), - ) - .await; - harness - .submit("run a long command with a short timeout") - .await?; - - let output = harness.function_call_stdout(call_id).await; - let normalized_output = output - .replace("\r\n", "\n") - .replace('\r', "\n") - .trim_end_matches('\n') - .to_string(); - let expected_pattern = r"(?s)^Exit code: 124\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\ncommand timed out after [0-9]+ milliseconds\n?$"; - assert_regex_match(expected_pattern, &normalized_output); - - Ok(()) -} - -/// This test verifies that a shell, particularly PowerShell, can correctly -/// handle unicode output when the UTF-8 BOM is used. See -/// https://github.com/openai/codex/pull/7902 for more context. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(true ; "with_login")] -#[test_case(false ; "without_login")] -async fn unicode_output(login: bool) -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.2")).await?; - - let call_id = "unicode_output"; - let command = if cfg!(windows) { - // We use a child process on Windows instead of a PowerShell command - // like `Write-Output` to ensure that the Powershell config is set - // correctly. - "cmd.exe /c echo naïve_café" - } else { - "echo \"naïve_café\"" - }; - mount_shell_responses_with_timeout(&harness, call_id, command, Some(login), MEDIUM_TIMEOUT) - .await; - harness.submit("run the command without login").await?; - - let output = harness.function_call_stdout(call_id).await; - assert_shell_command_output(&output, "naïve_café")?; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[test_case(true ; "with_login")] -#[test_case(false ; "without_login")] -async fn unicode_output_with_newlines(login: bool) -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.2")).await?; - - let call_id = "unicode_output"; - mount_shell_responses_with_timeout( - &harness, - call_id, - "echo 'line1\nnaïve café\nline3'", - Some(login), - MEDIUM_TIMEOUT, - ) - .await; - harness.submit("run the command without login").await?; - - let output = harness.function_call_stdout(call_id).await; - assert_shell_command_output(&output, "line1\\nnaïve café\\nline3")?; - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs deleted file mode 100644 index a428014ed9..0000000000 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ /dev/null @@ -1,456 +0,0 @@ -#![cfg(not(target_os = "windows"))] - -use anyhow::Result; -use codex_protocol::models::PermissionProfile; -use core_test_support::assert_regex_match; -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_sequence; -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::skip_if_target_windows; -use core_test_support::test_codex::test_codex; -use pretty_assertions::assert_eq; -use regex_lite::Regex; -use serde_json::Value; -use serde_json::json; -use std::fs; - -use crate::suite::apply_patch_cli::apply_patch_harness; -use crate::suite::apply_patch_cli::mount_apply_patch; - -const FIXTURE_JSON: &str = r#"{ - "description": "This is an example JSON file.", - "foo": "bar", - "isTest": true, - "testNumber": 123, - "testArray": [1, 2, 3], - "testObject": { - "foo": "bar" - } -} -"#; - -fn shell_responses(call_id: &str, command: Vec<&str>) -> Result> { - let command = shlex::try_join(command)?; - let parameters = json!({ - "command": command, - "timeout_ms": 2_000, - }); - Ok(vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - call_id, - "shell_command", - &serde_json::to_string(¶meters)?, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ]) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_output_preserves_fixture_json_as_freeform() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_model("test-gpt-5-codex"); - let test = builder.build(&server).await?; - - let fixture_path = test.cwd.path().join("fixture.json"); - fs::write(&fixture_path, FIXTURE_JSON)?; - let fixture_path_str = fixture_path.to_string_lossy().to_string(); - - let call_id = "shell-freeform-fixture"; - let responses = shell_responses( - call_id, - vec!["/usr/bin/sed", "-n", "p", fixture_path_str.as_str()], - )?; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "read the fixture JSON with shell output", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock.last_request().expect("shell output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("shell output string"); - - assert!( - serde_json::from_str::(output).is_err(), - "expected shell output to be plain text" - ); - let (header, body) = output - .split_once("Output:\n") - .expect("shell output contains an Output section"); - assert_regex_match( - r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds$", - header.trim_end(), - ); - assert_eq!( - body, FIXTURE_JSON, - "expected Output section to include the fixture contents" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_output_records_duration() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_model("test-gpt-5-codex"); - let test = builder.build(&server).await?; - - let call_id = "shell-freeform"; - let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "sleep 0.2"])?; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile("run the shell command", PermissionProfile::Disabled) - .await?; - - let req = mock.last_request().expect("shell output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("shell output string"); - - let expected_pattern = r#"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -$"#; - assert_regex_match(expected_pattern, output); - - let wall_time_regex = Regex::new(r"(?m)^Wall (?:time|Clock): ([0-9]+(?:\.[0-9]+)?) seconds$") - .expect("compile wall time regex"); - let wall_time_seconds = wall_time_regex - .captures(output) - .and_then(|caps| caps.get(1)) - .and_then(|value| value.as_str().parse::().ok()) - .expect("expected shell output to contain wall time seconds"); - assert!( - wall_time_seconds > 0.1, - "expected wall time to be greater than zero seconds, got {wall_time_seconds}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn apply_patch_custom_tool_call_creates_file() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = apply_patch_harness().await?; - - let call_id = "apply-patch-add-file"; - let file_name = "custom_tool_apply_patch.txt"; - let patch = format!( - "*** Begin Patch\n*** Add File: {file_name}\n+custom tool content\n*** End Patch\n" - ); - mount_apply_patch(&harness, call_id, &patch, "apply_patch done").await; - - harness - .test() - .submit_turn_with_permission_profile( - "apply the patch via custom tool to create a file", - PermissionProfile::Disabled, - ) - .await?; - - let output = harness.apply_patch_output(call_id).await; - - let expected_pattern = format!( - r"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -Success. Updated the following files: -A {file_name} -?$" - ); - assert_regex_match(&expected_pattern, output.as_str()); - - let created_contents = harness.read_file_text(file_name).await?; - assert_eq!( - created_contents, "custom tool content\n", - "expected file contents for {file_name}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn apply_patch_custom_tool_call_updates_existing_file() -> Result<()> { - skip_if_no_network!(Ok(())); - - let harness = apply_patch_harness().await?; - - let call_id = "apply-patch-update-file"; - let file_name = "custom_tool_apply_patch_existing.txt"; - harness.write_file(file_name, "before\n").await?; - let patch = format!( - "*** Begin Patch\n*** Update File: {file_name}\n@@\n-before\n+after\n*** End Patch\n" - ); - mount_apply_patch(&harness, call_id, &patch, "apply_patch update done").await; - - harness - .test() - .submit_turn_with_permission_profile( - "apply the patch via custom tool to update a file", - PermissionProfile::Disabled, - ) - .await?; - - let output = harness.apply_patch_output(call_id).await; - - let expected_pattern = format!( - r"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -Success. Updated the following files: -M {file_name} -?$" - ); - assert_regex_match(&expected_pattern, output.as_str()); - - let updated_contents = harness.read_file_text(file_name).await?; - assert_eq!(updated_contents, "after\n", "expected updated file content"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn apply_patch_custom_tool_call_reports_failure_output() -> Result<()> { - // TODO(anp): Remove after apply-patch assertions use target-native paths. - skip_if_target_windows!(Ok(()), "asserts POSIX apply_patch failure text"); - skip_if_no_network!(Ok(())); - - let harness = apply_patch_harness().await?; - - let call_id = "apply-patch-failure"; - let missing_file = "missing_custom_tool_apply_patch.txt"; - let patch = format!( - "*** Begin Patch\n*** Update File: {missing_file}\n@@\n-before\n+after\n*** End Patch\n" - ); - mount_apply_patch(&harness, call_id, &patch, "apply_patch failure done").await; - - harness - .test() - .submit_turn_with_permission_profile( - "attempt a failing apply_patch via custom tool", - PermissionProfile::Disabled, - ) - .await?; - - let output = harness.apply_patch_output(call_id).await; - - let expected_output = format!( - "apply_patch verification failed: Failed to read file to update {}/{missing_file}: No such file or directory (os error 2)", - harness.cwd().to_string_lossy() - ); - assert_eq!(output, expected_output.as_str()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_output_is_freeform_for_nonzero_exit() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_model("gpt-5.4"); - let test = builder.build(&server).await?; - - let call_id = "shell-nonzero-exit"; - let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "exit 42"])?; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "run the failing shell command", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock.last_request().expect("shell output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("shell output string"); - - let expected_pattern = r"(?s)^Exit code: 42 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -?$"; - assert_regex_match(expected_pattern, output); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_output_is_freeform() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex(); - let test = builder.build(&server).await?; - - let call_id = "shell-command"; - let args = json!({ - "command": "echo shell command", - "login": false, - "timeout_ms": 1_000, - }); - let responses = vec![ - sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "shell_command done"), - ev_completed("resp-2"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "run the shell_command script in the user's shell", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock - .last_request() - .expect("shell_command output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("shell_command output string"); - - let expected_pattern = r"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -shell command -?$"; - assert_regex_match(expected_pattern, output); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_output_is_not_truncated_under_10k_bytes() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_model("gpt-5.4"); - let test = builder.build(&server).await?; - - let call_id = "shell-command"; - let args = json!({ - "command": "perl -e 'print \"1\" x 10000'", - "login": false, - "timeout_ms": 1000, - }); - let responses = vec![ - sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "shell_command done"), - ev_completed("resp-2"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "run the shell_command script in the user's shell", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock - .last_request() - .expect("shell_command output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("shell_command output string"); - - let expected_pattern = r"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -1{10000}$"; - assert_regex_match(expected_pattern, output); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_output_is_not_truncated_over_10k_bytes() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_model("gpt-5.2"); - let test = builder.build(&server).await?; - - let call_id = "shell-command"; - let args = json!({ - "command": "perl -e 'print \"1\" x 10001'", - "login": false, - "timeout_ms": 1000, - }); - let responses = vec![ - sse(vec![ - json!({"type": "response.created", "response": {"id": "resp-1"}}), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "shell_command done"), - ev_completed("resp-2"), - ]), - ]; - let mock = mount_sse_sequence(&server, responses).await; - - test.submit_turn_with_permission_profile( - "run the shell_command script in the user's shell", - PermissionProfile::Disabled, - ) - .await?; - - let req = mock - .last_request() - .expect("shell_command output request recorded"); - let output_item = req.function_call_output(call_id); - let output = output_item - .get("output") - .and_then(Value::as_str) - .expect("shell_command output string"); - - let expected_pattern = r"(?s)^Exit code: 0 -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Output: -1*…1 chars truncated…1*$"; - assert_regex_match(expected_pattern, output); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index ca6eeb389a..140606d107 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -215,101 +215,6 @@ async fn run_snapshot_command_with_options( }) } -async fn run_shell_command_snapshot(command: &str) -> Result { - run_shell_command_snapshot_with_options(command, SnapshotRunOptions::default()).await -} - -async fn run_shell_command_snapshot_with_options( - command: &str, - options: SnapshotRunOptions, -) -> Result { - let SnapshotRunOptions { - shell_environment_set, - } = options; - let builder = test_codex().with_config(move |config| { - config - .features - .enable(Feature::ShellSnapshot) - .expect("test config should allow feature update"); - config.permissions.shell_environment_policy.r#set = shell_environment_set; - }); - let harness = TestCodexHarness::with_builder(builder).await?; - let args = json!({ - "command": command, - "timeout_ms": 1000, - }); - let call_id = "shell-snapshot-command"; - let responses = vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ]; - mount_sse_sequence(harness.server(), responses).await; - - let test = harness.test(); - let codex = test.codex.clone(); - let codex_home = test.home.path().to_path_buf(); - let session_model = test.session_configured.model.clone(); - let cwd = test.config.cwd.clone(); - let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); - - codex - .start_or_steer_turn( - TurnInputRequest::user_input(vec![UserInput::Text { - text: "run shell_command with shell snapshot".into(), - text_elements: Vec::new(), - }]) - .with_thread_settings(ThreadSettingsOverrides { - environments: Some(local_selections(cwd)), - approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(CollaborationMode { - mode: ModeKind::Default, - settings: Settings { - model: session_model, - reasoning_effort: None, - developer_instructions: None, - }, - }), - ..Default::default() - }), - ) - .await?; - - let begin = wait_for_event_match(&codex, |ev| match ev { - EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => Some(ev.clone()), - _ => None, - }) - .await; - let snapshot_path = wait_for_snapshot(&codex_home).await?; - let snapshot_content = fs::read_to_string(&snapshot_path).await?; - - let end = wait_for_event_match(&codex, |ev| match ev { - EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), - _ => None, - }) - .await; - - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - - Ok(SnapshotRun { - begin, - end, - snapshot_path, - snapshot_content, - codex_home, - }) -} - async fn run_tool_turn_on_harness( harness: &TestCodexHarness, prompt: &str, @@ -413,76 +318,7 @@ async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> { #[cfg_attr(target_os = "windows", ignore)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn linux_shell_command_uses_shell_snapshot() -> Result<()> { - let command = "echo shell-command-snapshot-linux"; - let run = run_shell_command_snapshot(command).await?; - - assert_eq!(run.begin.command.get(1).map(String::as_str), Some("-lc")); - assert_eq!(run.begin.command.get(2).map(String::as_str), Some(command)); - assert_eq!(run.begin.command.len(), 3); - assert!(run.snapshot_path.starts_with(&run.codex_home)); - assert_posix_snapshot_sections(&run.snapshot_content); - assert_eq!( - normalize_newlines(&run.end.stdout).trim(), - "shell-command-snapshot-linux" - ); - assert_eq!(run.end.exit_code, 0); - - Ok(()) -} - -#[cfg_attr(target_os = "windows", ignore)] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_snapshot_preserves_shell_environment_policy_set() -> Result<()> { - let builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::ShellSnapshot) - .expect("test config should allow feature update"); - config.permissions.shell_environment_policy.r#set = policy_set_path_for_test(); - }); - let harness = TestCodexHarness::with_builder(builder).await?; - let codex_home = harness.test().home.path().to_path_buf(); - run_tool_turn_on_harness( - &harness, - "warm up shell snapshot", - "shell-snapshot-policy-warmup", - "shell_command", - json!({ - "command": "printf warmup", - "timeout_ms": 1_000, - }), - ) - .await?; - let snapshot_path = wait_for_snapshot(&codex_home).await?; - fs::write(&snapshot_path, snapshot_override_content_for_policy_test()).await?; - - let command = command_asserting_policy_after_snapshot(); - let end = run_tool_turn_on_harness( - &harness, - "verify shell policy after snapshot", - "shell-snapshot-policy-assert", - "shell_command", - json!({ - "command": command, - "timeout_ms": 1_000, - }), - ) - .await?; - - assert_eq!( - normalize_newlines(&end.stdout).trim(), - POLICY_SUCCESS_OUTPUT - ); - assert_eq!(end.exit_code, 0); - assert!(snapshot_path.starts_with(codex_home)); - - Ok(()) -} - -#[cfg_attr(not(target_os = "linux"), ignore)] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn linux_unified_exec_snapshot_preserves_shell_environment_policy_set() -> Result<()> { +async fn unified_exec_snapshot_preserves_shell_environment_policy_set() -> Result<()> { let builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; config @@ -536,7 +372,7 @@ async fn linux_unified_exec_snapshot_preserves_shell_environment_policy_set() -> #[cfg_attr(target_os = "windows", ignore)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { +async fn unified_exec_snapshot_still_intercepts_apply_patch() -> Result<()> { let builder = test_codex().with_config(|config| { config .features @@ -553,17 +389,17 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let script = "apply_patch <<'EOF'\n*** Begin Patch\n*** Add File: snapshot-apply.txt\n+hello from snapshot\n*** End Patch\nEOF\n"; let args = json!({ - "command": script, + "cmd": script, // Keep this above the default because intercepted apply_patch still // performs filesystem work that can be slow in Bazel macOS test // environments. - "timeout_ms": 5_000, + "yield_time_ms": 5_000, }); let call_id = "shell-snapshot-apply-patch"; let responses = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -580,7 +416,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { codex .start_or_steer_turn( TurnInputRequest::user_input(vec![UserInput::Text { - text: "apply patch via shell_command with snapshot".into(), + text: "apply patch via unified_exec with snapshot".into(), text_elements: Vec::new(), }]) .with_thread_settings(ThreadSettingsOverrides { @@ -675,7 +511,7 @@ async fn shell_snapshot_deleted_after_shutdown_with_skills() -> Result<()> { #[cfg(target_os = "macos")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn macos_shell_command_resolves_command_from_tied_path_snapshot() -> Result<()> { +async fn macos_unified_exec_resolves_command_from_tied_path_snapshot() -> Result<()> { let builder = test_codex() .with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("/bin/zsh"))) .with_config(|config| { @@ -702,10 +538,10 @@ async fn macos_shell_command_resolves_command_from_tied_path_snapshot() -> Resul &harness, "warm up the tied PATH shell snapshot", "shell-snapshot-tied-path-warmup", - "shell_command", + "exec_command", json!({ - "command": "printf warmup", - "timeout_ms": 5_000, + "cmd": "printf warmup", + "yield_time_ms": 5_000, }), ) .await?; @@ -723,10 +559,10 @@ async fn macos_shell_command_resolves_command_from_tied_path_snapshot() -> Resul &harness, "resolve a command from the tied PATH snapshot", "shell-snapshot-tied-path", - "shell_command", + "exec_command", json!({ - "command": "snapshot-only-command", - "timeout_ms": 5_000, + "cmd": "snapshot-only-command", + "yield_time_ms": 5_000, }), ) .await?; diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index ba487a7964..b62848b6bd 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -35,10 +35,10 @@ fn write_skill_metadata(home: &Path, name: &str, contents: &str) -> Result<()> { Ok(()) } -fn shell_command_arguments(command: &str) -> Result { +fn exec_command_arguments(command: &str) -> Result { Ok(serde_json::to_string(&serde_json::json!({ - "command": command, - "timeout_ms": 500, + "cmd": command, + "yield_time_ms": 500, }))?) } @@ -192,9 +192,9 @@ async fn shell_zsh_fork_skill_scripts_ignore_declared_permissions() -> Result<() let command = skill_script_command(&test, "sandboxed.sh")?; let call_id = "zsh-fork-skill-script-ignores-permissions"; - let arguments = shell_command_arguments(&command)?; + let arguments = exec_command_arguments(&command)?; let mocks = - mount_function_call_agent_response(&server, call_id, &arguments, "shell_command").await; + mount_function_call_agent_response(&server, call_id, &arguments, "exec_command").await; submit_turn_with_policies( &test, @@ -259,10 +259,9 @@ async fn shell_zsh_fork_still_enforces_workspace_write_sandbox() -> Result<()> { .await?; let command = format!("touch {outside_path}"); - let arguments = shell_command_arguments(&command)?; + let arguments = exec_command_arguments(&command)?; let mocks = - mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command") - .await; + mount_function_call_agent_response(&server, tool_call_id, &arguments, "exec_command").await; submit_turn_with_policies( &test, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 34e7ff0ea0..509880616b 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -85,7 +85,7 @@ fn test_model_info( description: Some(description.to_string()), default_reasoning_level: Some(default_reasoning_level), supported_reasoning_levels, - shell_type: ConfigShellToolType::ShellCommand, + shell_type: ConfigShellToolType::UnifiedExec, visibility, supported_in_api: true, input_modalities: default_input_modalities(), diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index d4ad40410a..a5c8c08923 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -814,8 +814,8 @@ async fn tool_call_logs_include_thread_id() -> Result<()> { let server = start_mock_server().await; let call_id = "call-1"; let args = json!({ - "command": "echo hello", - "timeout_ms": 1_000, + "cmd": "echo hello", + "yield_time_ms": 1_000, "login": false, }); let args_json = serde_json::to_string(&args)?; @@ -824,7 +824,7 @@ async fn tool_call_logs_include_thread_id() -> Result<()> { vec![ responses::sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &args_json), + ev_function_call(call_id, "exec_command", &args_json), ev_completed("resp-1"), ]), responses::sse(vec![ev_completed("resp-2")]), @@ -850,7 +850,7 @@ async fn tool_call_logs_include_thread_id() -> Result<()> { tracing::dispatcher::with_default(&dispatch, || { let span = tracing::info_span!("test_log_span", thread_id = %expected_thread_id); let _entered = span.enter(); - tracing::info!("ToolCall: shell_command {{\"command\":\"echo hello\"}}"); + tracing::info!("ToolCall: exec_command {{\"cmd\":\"echo hello\"}}"); }); log_db_layer.flush().await; diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 4406d7a426..585effbb8b 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -33,9 +33,9 @@ use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; +use core_test_support::responses::ev_exec_command_call; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; -use core_test_support::responses::ev_shell_command_call; use core_test_support::responses::mount_compact_json_once; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; @@ -1254,7 +1254,7 @@ async fn token_budget_auto_compact_fallback_uses_buffer_until_new_context() -> R ]), sse(vec![ ev_response_created("fallback-tool-resp"), - ev_shell_command_call(fallback_call_id, "echo fallback-note > fallback-note.txt"), + ev_exec_command_call(fallback_call_id, "echo fallback-note > fallback-note.txt"), ev_completed_with_tokens("fallback-tool-resp", /*total_tokens*/ 10_000), ]), sse(vec![ diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index fb35299931..5362d1ebd0 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -62,7 +62,7 @@ fn custom_call_output(req: &ResponsesRequest, call_id: &str) -> (String, Option< } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Result<()> { +async fn exec_command_tool_executes_command_and_streams_output() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -75,15 +75,15 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res .. } = builder.build(&server).await?; - let call_id = "shell-command-tool-call"; + let call_id = "exec-command-tool-call"; let command_args = json!({ - "command": "echo tool harness", + "cmd": "echo tool harness", "login": false, }) .to_string(); let first_response = sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &command_args), + ev_function_call(call_id, "exec_command", &command_args), ev_completed("resp-1"), ]); responses::mount_sse_once(&server, first_response).await; @@ -128,7 +128,7 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res let req = second_mock.single_request(); let (output_text, _) = call_output(&req, call_id); assert_regex_match( - r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\ntool harness\n?$", + r"(?s)^(?:Chunk ID: [^\n]+\n)?Wall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\n(?:Original token count: \d+\n)?Output:\ntool harness\n?$", &output_text, ); diff --git a/codex-rs/core/tests/suite/tool_lifecycle.rs b/codex-rs/core/tests/suite/tool_lifecycle.rs index a51817da2b..9e5dee9c1f 100644 --- a/codex-rs/core/tests/suite/tool_lifecycle.rs +++ b/codex-rs/core/tests/suite/tool_lifecycle.rs @@ -250,9 +250,9 @@ async fn tool_start_is_not_called_when_pre_tool_hook_prevents_execution() -> Res }), ), ( - "shell_command", + "exec_command", "^Bash$", - json!({ "command": "echo original" }), + json!({ "cmd": "echo original" }), json!({ "hookSpecificOutput": { "hookEventName": "PreToolUse", diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 1958df84fe..e7b628b819 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -17,9 +17,9 @@ use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_exec_command_call_with_args; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; -use core_test_support::responses::ev_shell_command_call_with_args; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -160,18 +160,18 @@ async fn shell_tools_run_in_parallel() -> anyhow::Result<()> { let test = builder.build(&server).await?; let shell_args = json!({ - "command": "sleep 0.25", + "cmd": "sleep 0.25", // Avoid user-specific shell startup cost (e.g. zsh profile scripts) in timing assertions. "login": false, - "timeout_ms": 1_000, + "yield_time_ms": 1_000, }); let args_one = serde_json::to_string(&shell_args)?; let args_two = serde_json::to_string(&shell_args)?; let first_response = sse(vec![ json!({"type": "response.created", "response": {"id": "resp-1"}}), - ev_function_call("call-1", "shell_command", &args_one), - ev_function_call("call-2", "shell_command", &args_two), + ev_function_call("call-1", "exec_command", &args_one), + ev_function_call("call-2", "exec_command", &args_two), ev_completed("resp-1"), ]); let second_response = sse(vec![ @@ -180,7 +180,7 @@ async fn shell_tools_run_in_parallel() -> anyhow::Result<()> { ]); mount_sse_sequence(&server, vec![first_response, second_response]).await; - let duration = run_turn_and_measure(&test, "run shell_command twice").await?; + let duration = run_turn_and_measure(&test, "run exec_command twice").await?; assert_parallel_duration(duration); Ok(()) @@ -198,16 +198,16 @@ async fn mixed_parallel_tools_run_in_parallel() -> anyhow::Result<()> { }) .to_string(); let shell_args = serde_json::to_string(&json!({ - "command": "sleep 0.25", + "cmd": "sleep 0.25", // Avoid user-specific shell startup cost in timing assertions. "login": false, - "timeout_ms": 1_000, + "yield_time_ms": 1_000, }))?; let first_response = sse(vec![ json!({"type": "response.created", "response": {"id": "resp-1"}}), ev_function_call("call-1", "test_sync_tool", &sync_args), - ev_function_call("call-2", "shell_command", &shell_args), + ev_function_call("call-2", "exec_command", &shell_args), ev_completed("resp-1"), ]); let second_response = sse(vec![ @@ -230,17 +230,17 @@ async fn tool_results_grouped() -> anyhow::Result<()> { let test = build_codex_with_test_tool(&server).await?; let shell_args = serde_json::to_string(&json!({ - "command": "echo 'shell output'", - "timeout_ms": 1_000, + "cmd": "echo 'shell output'", + "yield_time_ms": 1_000, }))?; mount_sse_once( &server, sse(vec![ json!({"type": "response.created", "response": {"id": "resp-1"}}), - ev_function_call("call-1", "shell_command", &shell_args), - ev_function_call("call-2", "shell_command", &shell_args), - ev_function_call("call-3", "shell_command", &shell_args), + ev_function_call("call-1", "exec_command", &shell_args), + ev_function_call("call-2", "exec_command", &shell_args), + ev_function_call("call-3", "exec_command", &shell_args), ev_completed("resp-1"), ]), ) @@ -316,17 +316,17 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an // Use a non-login shell to avoid slow, user-specific shell init (e.g. zsh profiles) // from making this timing-based test flaky. let args = json!({ - "command": command, + "cmd": command, "login": false, - "timeout_ms": 5_000, + "yield_time_ms": 5_000, }); let first_chunk = sse(vec![ ev_response_created(first_response_id), - ev_shell_command_call_with_args("call-1", &args), - ev_shell_command_call_with_args("call-2", &args), - ev_shell_command_call_with_args("call-3", &args), - ev_shell_command_call_with_args("call-4", &args), + ev_exec_command_call_with_args("call-1", &args), + ev_exec_command_call_with_args("call-2", &args), + ev_exec_command_call_with_args("call-3", &args), + ev_exec_command_call_with_args("call-4", &args), ]); let second_chunk = sse(vec![ev_completed(first_response_id)]); let follow_up = sse(vec![ diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 4c8822698a..f66216a939 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -2,8 +2,6 @@ #![allow(clippy::unwrap_used)] use std::fs; -use std::time::Duration; -use std::time::Instant; use anyhow::Context; use anyhow::Result; @@ -17,6 +15,7 @@ use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; use codex_protocol::dynamic_tools::DynamicToolNamespaceTool; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -45,7 +44,6 @@ use core_test_support::submit_thread_settings; use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; -use regex_lite::Regex; use serde_json::Value; use serde_json::json; use test_case::test_case; @@ -548,7 +546,7 @@ async fn namespaced_custom_tool_call_preserves_namespace_through_dispatch_and_re } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> { +async fn exec_command_escalated_permissions_rejected_then_ok() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -569,19 +567,19 @@ async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> { .await?; let command = "echo shell ok"; - let call_id_blocked = "shell-command-blocked"; - let call_id_success = "shell-command-success"; + let call_id_blocked = "exec-command-blocked"; + let call_id_success = "exec-command-success"; let first_args = json!({ - "command": command, + "cmd": command, "login": false, - "timeout_ms": 1_000, + "yield_time_ms": 1_000, "sandbox_permissions": SandboxPermissions::RequireEscalated, }); let second_args = json!({ - "command": command, + "cmd": command, "login": false, - "timeout_ms": 1_000, + "yield_time_ms": 10_000, }); mount_sse_once( @@ -590,7 +588,7 @@ async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> { ev_response_created("resp-1"), ev_function_call( call_id_blocked, - "shell_command", + "exec_command", &serde_json::to_string(&first_args)?, ), ev_completed("resp-1"), @@ -603,7 +601,7 @@ async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> { ev_response_created("resp-2"), ev_function_call( call_id_success, - "shell_command", + "exec_command", &serde_json::to_string(&second_args)?, ), ev_completed("resp-2"), @@ -619,12 +617,11 @@ async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> { ) .await; - test.submit_text_turn("run the shell_command script") - .await?; + test.submit_text_turn("run the exec_command script").await?; let policy = AskForApproval::Never; let expected_message = format!( - "approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}" + "approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}" ); let blocked_output = second_mock @@ -643,7 +640,7 @@ async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> { .and_then(|(content, _)| content) .expect("success output string"); assert_regex_match( - r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\nshell ok\n?$", + r"(?s)^(?:Chunk ID: [^\n]+\n)?Wall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\n(?:Original token count: \d+\n)?Output:\nshell ok\n?$", &success_output, ); @@ -651,32 +648,32 @@ async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn sandbox_denied_shell_command_returns_original_output() -> Result<()> { +async fn sandbox_denied_exec_command_returns_original_output() -> Result<()> { skip_if_no_network!(Ok(())); 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 = "sandbox-denied-shell-command"; + let call_id = "sandbox-denied-exec-command"; let target_path = fixture.workspace_path("sandbox-denied.txt"); let sentinel = "sandbox-denied sentinel output"; let command = format!( - "printf {sentinel:?}; printf {content:?} > {path:?}", + "printf {sentinel:?} >&2; printf {content:?} > {path:?}", sentinel = format!("{sentinel}\n"), content = "sandbox denied", path = &target_path ); let args = json!({ - "command": command, + "cmd": command, "login": false, - "timeout_ms": 5_000, + "yield_time_ms": 5_000, }); let responses = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -696,13 +693,10 @@ async fn sandbox_denied_shell_command_returns_original_output() -> Result<()> { let output_text = mock .function_call_output_text(call_id) .context("shell output present")?; - let exit_code_line = output_text + let exit_code = output_text .lines() - .next() - .context("exit code line present")?; - let exit_code = exit_code_line - .strip_prefix("Exit code: ") - .context("exit code prefix present")? + .find_map(|line| line.strip_prefix("Process exited with code ")) + .context("exit code line present")? .trim() .parse::() .context("exit code is integer")?; @@ -741,7 +735,7 @@ async fn sandbox_denied_shell_command_returns_original_output() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_enforces_glob_deny_read_policy() -> Result<()> { +async fn exec_command_enforces_glob_deny_read_policy() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); @@ -778,22 +772,22 @@ async fn shell_command_enforces_glob_deny_read_policy() -> Result<()> { fs::write(&denied_path, format!("{secret}\n")).context("write denied fixture")?; fs::write(&allowed_path, format!("{allowed}\n")).context("write allowed fixture")?; - let call_id = "shell-command-glob-deny-read"; + let call_id = "exec-command-glob-deny-read"; let command = format!( "rc=0; cat {denied_path:?} || rc=$?; cat {allowed_path:?}; exit \"$rc\"", denied_path = denied_path.to_string_lossy(), allowed_path = allowed_path.to_string_lossy(), ); let args = json!({ - "command": command, + "cmd": command, "login": false, - "timeout_ms": 1_000, + "yield_time_ms": 10_000, }); let responses = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -811,13 +805,10 @@ async fn shell_command_enforces_glob_deny_read_policy() -> Result<()> { let output_text = mock .function_call_output_text(call_id) .context("shell output present")?; - let exit_code_line = output_text + let exit_code = output_text .lines() - .next() - .context("exit code line present")?; - let exit_code = exit_code_line - .strip_prefix("Exit code: ") - .context("exit code prefix present")? + .find_map(|line| line.strip_prefix("Process exited with code ")) + .context("exit code line present")? .trim() .parse::() .context("exit code is integer")?; @@ -846,7 +837,15 @@ async fn shell_command_enforces_glob_deny_read_policy() -> Result<()> { Ok(()) } -async fn collect_tools(use_unified_exec: bool) -> Result> { +#[derive(Clone, Copy, Debug)] +enum CommandToolAvailability { + Default, + LegacyUnifiedExecDisabled, + ShellToolDisabled, + ModelDisabled, +} + +async fn collect_tools(availability: CommandToolAvailability) -> Result> { let server = start_mock_server().await; let responses = vec![sse(vec![ @@ -856,19 +855,26 @@ async fn collect_tools(use_unified_exec: bool) -> Result> { ])]; let mock = mount_sse_sequence(&server, responses).await; - let mut builder = test_codex().with_config(move |config| { - if use_unified_exec { - config - .features - .enable(Feature::UnifiedExec) - .expect("test config should allow feature update"); - } else { + let mut builder = match availability { + CommandToolAvailability::Default => test_codex(), + CommandToolAvailability::LegacyUnifiedExecDisabled => test_codex().with_config(|config| { config .features .disable(Feature::UnifiedExec) .expect("test config should allow feature update"); + }), + CommandToolAvailability::ShellToolDisabled => test_codex().with_config(|config| { + config + .features + .disable(Feature::ShellTool) + .expect("test config should allow feature update"); + }), + CommandToolAvailability::ModelDisabled => { + test_codex().with_model_info_override("gpt-5.4", |model| { + model.shell_type = ConfigShellToolType::Disabled; + }) } - }); + }; let test = builder.build(&server).await?; test.submit_turn_with_approval_and_permission_profile( @@ -886,207 +892,30 @@ async fn collect_tools(use_unified_exec: bool) -> Result> { async fn unified_exec_spec_toggle_end_to_end() -> Result<()> { skip_if_no_network!(Ok(())); - let tools_disabled = collect_tools(/*use_unified_exec*/ false).await?; - assert!( - !tools_disabled.iter().any(|name| name == "exec_command"), - "tools list should not include exec_command when disabled: {tools_disabled:?}" - ); - assert!( - !tools_disabled.iter().any(|name| name == "write_stdin"), - "tools list should not include write_stdin when disabled: {tools_disabled:?}" - ); - - let tools_enabled = collect_tools(/*use_unified_exec*/ true).await?; - assert!( - tools_enabled.iter().any(|name| name == "exec_command"), - "tools list should include exec_command when enabled: {tools_enabled:?}" - ); - assert!( - tools_enabled.iter().any(|name| name == "write_stdin"), - "tools list should include write_stdin when enabled: {tools_enabled:?}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_timeout_includes_timeout_prefix_and_metadata() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_model("test-gpt-5-codex"); - let test = builder.build(&server).await?; - - let call_id = "shell-command-timeout"; - let timeout_ms = 50u64; - let args = json!({ - "command": "yes line | head -n 400; sleep 1", - "login": false, - "timeout_ms": timeout_ms, - }); - - 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 second_mock = mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ) - .await; - - test.submit_turn_with_approval_and_permission_profile( - "run a long command", - AskForApproval::Never, - PermissionProfile::Disabled, - ) - .await?; - - let timeout_item = second_mock.single_request().function_call_output(call_id); - - let output_str = timeout_item - .get("output") - .and_then(Value::as_str) - .expect("timeout output string"); - - // The exec path can report a timeout in two ways depending on timing: - // 1) Structured JSON with exit_code 124 and a timeout prefix (preferred), or - // 2) A plain error string if the child is observed as killed by a signal first. - if let Ok(output_json) = serde_json::from_str::(output_str) { - assert_eq!( - output_json["metadata"]["exit_code"].as_i64(), - Some(124), - "expected timeout exit code 124", - ); - - let stdout = output_json["output"].as_str().unwrap_or_default(); - assert!( - stdout.contains("command timed out"), - "timeout output missing `command timed out`: {stdout}" - ); - } else { - let normalized_output = output_str - .replace("\r\n", "\n") - .replace('\r', "\n") - .trim_end_matches('\n') - .to_string(); - - let shell_output_pattern = r"(?s)^Exit code: 124\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\ncommand timed out after [0-9]+ milliseconds\n(?:.*)?$"; - if Regex::new(shell_output_pattern) - .expect("shell timeout output regex should compile") - .is_match(&normalized_output) - { - return Ok(()); + for availability in [ + CommandToolAvailability::ShellToolDisabled, + CommandToolAvailability::ModelDisabled, + ] { + let tools = collect_tools(availability).await?; + for command_tool in ["exec_command", "write_stdin"] { + assert!( + !tools.iter().any(|name| name == command_tool), + "tools list should not include {command_tool} for {availability:?}: {tools:?}" + ); } + } - // Fallback: accept the signal classification path to deflake the test. - let signal_pattern = r"(?is)^execution error:.*signal.*$"; - assert_regex_match(signal_pattern, output_str); - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_timeout_handles_background_grandchild_stdout() -> 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 - .permissions - .set_permission_profile(PermissionProfile::Disabled) - .expect("set permission profile"); - }); - let test = builder.build(&server).await?; - - let call_id = "shell-command-grandchild-timeout"; - let pid_path = test.cwd.path().join("grandchild_pid.txt"); - let script_path = test.cwd.path().join("spawn_detached.py"); - let script = format!( - r#"import subprocess -import time -from pathlib import Path - -# Spawn a detached grandchild that inherits stdout/stderr so the pipe stays open. -proc = subprocess.Popen(["/bin/sh", "-c", "sleep 60"], start_new_session=True) -Path({pid_path:?}).write_text(str(proc.pid)) -time.sleep(60) -"# - ); - fs::write(&script_path, script)?; - - let args = json!({ - "command": format!("python3 {:?}", script_path.to_string_lossy()), - "login": false, - "timeout_ms": 200, - }); - - 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 second_mock = mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ) - .await; - - let start = Instant::now(); - let output_str = tokio::time::timeout(Duration::from_secs(10), async { - test.submit_turn_with_approval_and_permission_profile( - "run a command with a detached grandchild", - AskForApproval::Never, - PermissionProfile::Disabled, - ) - .await?; - let timeout_item = second_mock.single_request().function_call_output(call_id); - timeout_item - .get("output") - .and_then(Value::as_str) - .map(str::to_string) - .context("timeout output string") - }) - .await - .context("exec call should not hang waiting for grandchild pipes to close")??; - let elapsed = start.elapsed(); - - if let Ok(output_json) = serde_json::from_str::(&output_str) { - assert_eq!( - output_json["metadata"]["exit_code"].as_i64(), - Some(124), - "expected timeout exit code 124", - ); - } else { - let timeout_pattern = r"(?is)command timed out|timeout"; - assert_regex_match(timeout_pattern, &output_str); - } - - assert!( - elapsed < Duration::from_secs(9), - "command should return shortly after timeout even with live grandchildren: {elapsed:?}" - ); - - if let Ok(pid_str) = fs::read_to_string(&pid_path) - && let Ok(pid) = pid_str.trim().parse::() - { - unsafe { libc::kill(pid, libc::SIGKILL) }; + for availability in [ + CommandToolAvailability::Default, + CommandToolAvailability::LegacyUnifiedExecDisabled, + ] { + let tools = collect_tools(availability).await?; + for command_tool in ["exec_command", "write_stdin"] { + assert!( + tools.iter().any(|name| name == command_tool), + "tools list should include {command_tool} for {availability:?}: {tools:?}" + ); + } } Ok(()) diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 8d87942e08..974f59b9ff 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -13,6 +13,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::protocol::TruncationPolicy; use codex_protocol::user_input::UserInput; use core_test_support::TempDirExt; use core_test_support::assert_regex_match; @@ -43,7 +44,7 @@ fn assert_wall_time_header(output: &str) { assert_eq!(marker, "Output:"); } -// Verifies that a standard tool call (shell_command) exceeding the model formatting +// Verifies that a standard tool call (exec_command) exceeding the model formatting // limits is truncated before being sent back to the model. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_output_configured_limit_chars_type() -> Result<()> { @@ -51,7 +52,7 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> { let server = start_mock_server().await; - // Use a model that exposes the shell_command tool. + // Use a model that exposes the exec_command tool. let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| { config.tool_output_token_limit = Some(100_000); }); @@ -65,8 +66,9 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> { "seq 1 100000" }; let args = serde_json::json!({ - "command": command, - "timeout_ms": 5_000, + "cmd": command, + "yield_time_ms": 5_000, + "max_output_tokens": 100_000, }); // First response: model tells us to run the tool; second: complete the turn. @@ -74,7 +76,7 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> { &server, sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + responses::ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), responses::ev_completed("resp-1"), ]), ) @@ -110,19 +112,20 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> { ); assert!( - (400000..=401000).contains(&output.len()), - "we should be almost 100k tokens" + (400_000..=401_000).contains(&output.len()), + "expected output near the configured 100k-token budget, got {} bytes", + output.len() ); assert!( - !output.contains("tokens truncated"), - "shell output should not contain tokens truncated marker: {output}" + output.contains("chars truncated"), + "unified exec should preserve the model's byte-based truncation policy" ); Ok(()) } -// Verifies that a standard tool call (shell_command) exceeding the model formatting +// Verifies that a standard tool call (exec_command) exceeding the model formatting // limits is truncated before being sent back to the model. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> { @@ -130,7 +133,7 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> { let server = start_mock_server().await; - // Use a model that exposes the shell_command tool. + // Use a model that exposes the exec_command tool. let mut builder = test_codex().with_model("gpt-5.2"); let fixture = builder.build(&server).await?; @@ -142,8 +145,8 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> { "seq 1 100000" }; let args = serde_json::json!({ - "command": command, - "timeout_ms": 5_000, + "cmd": command, + "yield_time_ms": 5_000, }); // First response: model tells us to run the tool; second: complete the turn. @@ -151,7 +154,7 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> { &server, sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + responses::ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), responses::ev_completed("resp-1"), ]), ) @@ -186,20 +189,20 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> { "expected truncated shell output to be plain text" ); - let truncated_pattern = r#"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nTotal output lines: 100000\nOutput:\n.*?…\d+ chars truncated….*$"#; + let truncated_pattern = r#"(?s)^Chunk ID: [^\n]+\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\nOriginal token count: \d+\nOutput:\nWarning: truncated output \(original token count: \d+\)\nTotal output lines: 100000\n\n.*?…\d+ chars truncated….*$"#; assert_regex_match(truncated_pattern, &output); let len = output.len(); assert!( - (9_900..=10_100).contains(&len), + (9_900..=10_500).contains(&len), "expected ~10k chars after truncation, got {len}" ); Ok(()) } -// Verifies that a standard tool call (shell_command) exceeding the model formatting +// Verifies that a standard tool call (exec_command) exceeding the model formatting // limits is truncated before being sent back to the model. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> { @@ -207,7 +210,7 @@ async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> { let server = start_mock_server().await; - // Use a model that exposes the shell_command tool. + // Use a model that exposes the exec_command tool. let mut builder = test_codex().with_model("gpt-5.4"); let fixture = builder.build(&server).await?; @@ -218,8 +221,8 @@ async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> { "seq 1 100000" }; let args = serde_json::json!({ - "command": command, - "timeout_ms": 5_000, + "cmd": command, + "yield_time_ms": 5_000, }); // First response: model tells us to run the tool; second: complete the turn. @@ -227,7 +230,7 @@ async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> { &server, sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + responses::ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), responses::ev_completed("resp-1"), ]), ) @@ -261,17 +264,21 @@ async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> { serde_json::from_str::(&output).is_err(), "expected truncated shell output to be plain text" ); - let truncated_pattern = r#"(?s)^Exit code: 0 + let truncated_pattern = r#"(?s)^Chunk ID: [^\n]+ Wall time: [0-9]+(?:\.[0-9]+)? seconds -Total output lines: 100000 +Process exited with code 0 +Original token count: \d+ Output: +Warning: truncated output \(original token count: \d+\) +Total output lines: 100000 + 1 2 3 4 5 6 -.*…137224 tokens truncated.* +.*…\d+ tokens truncated.* 99999 100000 $"#; @@ -280,7 +287,7 @@ $"#; Ok(()) } -// Ensures shell_command outputs that exceed the line limit are truncated only once. +// Ensures exec_command outputs that exceed the line limit are truncated only once. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_output_truncated_only_once() -> Result<()> { skip_if_no_network!(Ok(())); @@ -296,15 +303,15 @@ async fn tool_call_output_truncated_only_once() -> Result<()> { "seq 1 10000" }; let args = serde_json::json!({ - "command": command, - "timeout_ms": 5_000, + "cmd": command, + "yield_time_ms": 5_000, }); mount_sse_once( &server, sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + responses::ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), responses::ev_completed("resp-1"), ]), ) @@ -585,15 +592,15 @@ async fn token_policy_marker_reports_tokens() -> Result<()> { let call_id = "shell-token-marker"; let args = json!({ - "command": "seq 1 150", - "timeout_ms": 5_000, + "cmd": "seq 1 150", + "yield_time_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_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) @@ -616,14 +623,16 @@ async fn token_policy_marker_reports_tokens() -> Result<()> { .function_call_output_text(call_id) .context("shell output present")?; - let pattern = r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nTotal output lines: 150\nOutput:\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19.*tokens truncated.*129\n130\n131\n132\n133\n134\n135\n136\n137\n138\n139\n140\n141\n142\n143\n144\n145\n146\n147\n148\n149\n150\n$"; + let pattern = r"(?s)^Chunk ID: [^\n]+\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\nOriginal token count: \d+\nOutput:\nWarning: truncated output \(original token count: \d+\)\nTotal output lines: 150\n\n1\n2\n3\n.*…\d+ tokens truncated….*149\n150\n$"; assert_regex_match(pattern, &output); + assert_eq!(output.matches("tokens truncated").count(), 1); + assert!(output.len() <= (TruncationPolicy::Tokens(50) * 1.2).byte_budget()); Ok(()) } -// Byte-based policy should report bytes removed. +// Byte-based policy should report characters removed. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn byte_policy_marker_reports_bytes() -> Result<()> { skip_if_no_network!(Ok(())); @@ -636,15 +645,15 @@ async fn byte_policy_marker_reports_bytes() -> Result<()> { let call_id = "shell-byte-marker"; let args = json!({ - "command": "seq 1 150", - "timeout_ms": 5_000, + "cmd": "seq 1 150", + "yield_time_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_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) @@ -667,16 +676,18 @@ async fn byte_policy_marker_reports_bytes() -> Result<()> { .function_call_output_text(call_id) .context("shell output present")?; - let pattern = r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nTotal output lines: 150\nOutput:\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19.*chars truncated.*129\n130\n131\n132\n133\n134\n135\n136\n137\n138\n139\n140\n141\n142\n143\n144\n145\n146\n147\n148\n149\n150\n$"; + let pattern = r"(?s)^Chunk ID: [^\n]+\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\nOriginal token count: \d+\nOutput:\nWarning: truncated output \(original token count: \d+\)\nTotal output lines: 150\n\n1\n2\n3\n.*…\d+ chars truncated….*149\n150\n$"; assert_regex_match(pattern, &output); + assert_eq!(output.matches("chars truncated").count(), 1); + assert!(output.len() <= (TruncationPolicy::Bytes(200) * 1.2).byte_budget()); Ok(()) } -// shell_command output should remain intact when the config opts into a large token budget. +// exec_command output should remain intact when the config opts into a large token budget. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_command_output_not_truncated_with_custom_limit() -> Result<()> { +async fn exec_command_output_not_truncated_with_custom_limit() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -687,8 +698,8 @@ async fn shell_command_output_not_truncated_with_custom_limit() -> Result<()> { let call_id = "shell-no-trunc"; let args = json!({ - "command": "seq 1 1000", - "timeout_ms": 5_000, + "cmd": "seq 1 1000", + "yield_time_ms": 5_000, }); let expected_body: String = (1..=1000).map(|i| format!("{i}\n")).collect(); @@ -696,7 +707,7 @@ async fn shell_command_output_not_truncated_with_custom_limit() -> Result<()> { &server, sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) diff --git a/codex-rs/core/tests/suite/turn_state.rs b/codex-rs/core/tests/suite/turn_state.rs index 30f69626a6..ce306dbe96 100644 --- a/codex-rs/core/tests/suite/turn_state.rs +++ b/codex-rs/core/tests/suite/turn_state.rs @@ -4,9 +4,9 @@ use anyhow::Result; use core_test_support::responses::WebSocketConnectionConfig; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_exec_command_call; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::ev_response_created; -use core_test_support::responses::ev_shell_command_call; use core_test_support::responses::mount_response_sequence; use core_test_support::responses::sse; use core_test_support::responses::sse_response; @@ -30,7 +30,7 @@ async fn responses_turn_state_persists_within_turn_and_resets_after() -> Result< let first_response = sse(vec![ ev_response_created("resp-1"), ev_reasoning_item("rsn-1", &["thinking"], &[]), - ev_shell_command_call(call_id, "echo turn-state"), + ev_exec_command_call(call_id, "echo turn-state"), ev_completed("resp-1"), ]); let second_response = sse(vec![ @@ -102,7 +102,7 @@ async fn websocket_turn_state_persists_within_turn_and_resets_after() -> Result< }), ev_response_created("resp-1"), ev_reasoning_item("rsn-1", &["thinking"], &[]), - ev_shell_command_call("ws-shell-turn-state", "echo websocket"), + ev_exec_command_call("ws-shell-turn-state", "echo websocket"), ev_completed("resp-1"), ], vec![ @@ -209,7 +209,7 @@ async fn websocket_turn_state_is_stable_within_turn() -> Result<()> { "headers": {(TURN_STATE_HEADER): "ts-1"}, }), ev_response_created("resp-1"), - ev_shell_command_call("ws-shell-1", "echo one"), + ev_exec_command_call("ws-shell-1", "echo one"), ev_completed("resp-1"), ], vec![ @@ -218,7 +218,7 @@ async fn websocket_turn_state_is_stable_within_turn() -> Result<()> { "headers": {(TURN_STATE_HEADER): "ts-2"}, }), ev_response_created("resp-2"), - ev_shell_command_call("ws-shell-2", "echo two"), + ev_exec_command_call("ws-shell-2", "echo two"), ev_completed("resp-2"), ], vec![ diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index d28dc6b79b..e94cedadb6 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -26,6 +26,7 @@ use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR; use codex_protocol::user_input::UserInput; use codex_utils_output_truncation::approx_tokens_from_byte_count; use codex_utils_path_uri::PathUri; @@ -304,6 +305,60 @@ async fn exec_command_hides_and_rejects_login_when_disabled() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_command_does_not_expose_configured_noise_auth_token() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "basic PowerShell execution through Wine is unavailable" + ); + + let builder = test_codex().with_model("gpt-5.4").with_config(|config| { + config.permissions.shell_environment_policy.r#set.insert( + CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_string(), + "configured-noise-token".to_string(), + ); + config.permissions.shell_environment_policy.r#set.insert( + CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_ascii_lowercase(), + "case-variant-noise-token".to_string(), + ); + }); + let harness = TestCodexHarness::with_auto_env_builder(builder).await?; + let command = match core_test_support::test_target_os() { + core_test_support::TestTargetOs::Linux | core_test_support::TestTargetOs::MacOs => { + "if [ -n \"${CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN:-}\" ] || [ -n \"${codex_exec_server_noise_auth_token:-}\" ]; then echo leaked; else echo unset; fi" + } + core_test_support::TestTargetOs::Windows => { + "if ($env:CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN) { Write-Output leaked } else { Write-Output unset }" + } + }; + let call_id = "exec-command-noise-auth-token"; + let arguments = json!({ "cmd": command, "yield_time_ms": 5_000 }); + mount_sse_sequence( + harness.server(), + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&arguments)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + harness + .submit("check the remote execution auth token") + .await?; + + let output = parse_unified_exec_output(&harness.function_call_stdout(call_id).await)?; + assert_eq!(output.output.trim(), "unset"); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_command_uses_installed_environment_shell_policy_with_explicit_overrides() -> Result<()> { @@ -1821,9 +1876,10 @@ async fn exec_command_clamps_model_requested_max_output_tokens_to_policy() -> Re assert_eq!(output.original_token_count, Some(8_991)); let output_text = output.output.replace("\r\n", "\n"); assert_regex_match( - r"^Warning: truncated output \(original token count: 8991\)\nTotal output lines: 999\n\nEXEC-LINE-0001 x{20}\nEXEC-LINE-0002 x{20}\nEXEC-LINE-0003 x{13}…8941 tokens truncated…E-0997 x{20}\nEXEC-LINE-0998 x{20}\nEXEC-LINE-0999 x{20}\n$", + r"(?s)^Warning: truncated output \(original token count: 8991\)\nTotal output lines: 999\n\nEXEC-LINE-.*…\d+ tokens truncated…x+\n$", &output_text, ); + assert_eq!(output_text.matches("tokens truncated").count(), 1); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -1911,9 +1967,10 @@ async fn write_stdin_clamps_model_requested_max_output_tokens_to_policy() -> Res assert_eq!(stdin_output.original_token_count, Some(9_492)); let stdin_output_text = stdin_output.output.replace("\r\n", "\n"); assert_regex_match( - r"^Warning: truncated output \(original token count: 9492\)\nTotal output lines: 1000\n\ngo\nSTDIN-LINE-0001 y{20}\nSTDIN-LINE-0002 y{20}\nSTDIN-LINE-0003 yyyy…9442 tokens truncated…7 y{20}\nSTDIN-LINE-0998 y{20}\nSTDIN-LINE-0999 y{20}\n$", + r"(?s)^Warning: truncated output \(original token count: 9492\)\nTotal output lines: 1000\n\ngo\nSTDIN.*…\d+ tokens truncated…y+\n$", &stdin_output_text, ); + assert_eq!(stdin_output_text.matches("tokens truncated").count(), 1); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 499c7d98d4..8e5107f872 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -189,18 +189,18 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> 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, + "cmd": "Start-Sleep -Seconds 2; Write-Output model-shell", + "yield_time_ms": 10_000, }) } else { serde_json::json!({ - "command": "sleep 2; echo model-shell", - "timeout_ms": 10_000, + "cmd": "sleep 2; echo model-shell", + "yield_time_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_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]); let second = sse(vec![ @@ -239,7 +239,9 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> .await?; let _ = wait_for_event_match(&fixture.codex, |ev| match ev { - EventMsg::ExecCommandBegin(event) if event.source == ExecCommandSource::Agent => { + EventMsg::ExecCommandBegin(event) + if event.source == ExecCommandSource::UnifiedExecStartup => + { Some(event.clone()) } _ => None, @@ -505,13 +507,13 @@ async fn user_shell_command_is_truncated_only_once() -> anyhow::Result<()> { 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, + "cmd": "for ($i=1; $i -le 2000; $i++) { Write-Output $i }", + "yield_time_ms": 5_000, }) } else { serde_json::json!({ - "command": "seq 1 2000", - "timeout_ms": 5_000, + "cmd": "seq 1 2000", + "yield_time_ms": 5_000, }) }; @@ -519,7 +521,7 @@ async fn user_shell_command_is_truncated_only_once() -> anyhow::Result<()> { &server, sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) @@ -535,7 +537,7 @@ async fn user_shell_command_is_truncated_only_once() -> anyhow::Result<()> { fixture .submit_turn_with_permission_profile( - "trigger big shell_command output", + "trigger big exec_command output", PermissionProfile::Disabled, ) .await?; @@ -543,13 +545,13 @@ async fn user_shell_command_is_truncated_only_once() -> anyhow::Result<()> { let output = mock2 .single_request() .function_call_output_text(call_id) - .context("function_call_output present for shell_command call")?; + .context("function_call_output present for exec_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}" + "exec_command output should carry only one truncation header: {output}" ); Ok(()) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index f39a1f3c77..daf24916e7 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1613,7 +1613,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an effort: ReasoningEffort::Medium, description: ReasoningEffort::Medium.to_string(), }], - shell_type: ConfigShellToolType::ShellCommand, + shell_type: ConfigShellToolType::UnifiedExec, visibility: ModelVisibility::List, supported_in_api: true, input_modalities: vec![InputModality::Text], diff --git a/codex-rs/core/tests/suite/windows_sandbox.rs b/codex-rs/core/tests/suite/windows_sandbox.rs index 8fb8f01ca5..fcf27c9fd1 100644 --- a/codex-rs/core/tests/suite/windows_sandbox.rs +++ b/codex-rs/core/tests/suite/windows_sandbox.rs @@ -396,8 +396,7 @@ async fn windows_elevated_enforces_deny_read_and_protects_setup_marker() -> anyh #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[serial(codex_home)] -async fn windows_elevated_shell_and_unified_exec_enforce_managed_deny_reads() -> anyhow::Result<()> -{ +async fn windows_elevated_unified_exec_enforces_managed_deny_reads() -> anyhow::Result<()> { let codex_home = codex_home_for_windows_sandbox_test("windows-elevated-tool-runtime-deny-read-codex-home")?; let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", codex_home.path().as_os_str()); @@ -471,13 +470,7 @@ async fn windows_elevated_shell_and_unified_exec_enforce_managed_deny_reads() -> "(type exact-secret.txt 1>NUL 2>NUL && echo EXACT-READ || echo EXACT-DENIED) & ", "type public.txt" ); - let shell_call_id = "windows-managed-deny-read-shell-command"; - let unified_call_id = "windows-managed-deny-read-exec-command"; - let shell_args = json!({ - "command": command, - "timeout_ms": 30_000, - "login": false, - }); + let call_id = "windows-managed-deny-read-exec-command"; let unified_args = json!({ "cmd": command, "yield_time_ms": 30_000, @@ -487,19 +480,10 @@ async fn windows_elevated_shell_and_unified_exec_enforce_managed_deny_reads() -> mount_sse_sequence( harness.server(), vec![ - sse(vec![ - ev_response_created("resp-windows-shell-deny-read"), - ev_function_call( - shell_call_id, - "shell_command", - &serde_json::to_string(&shell_args)?, - ), - ev_completed("resp-windows-shell-deny-read"), - ]), sse(vec![ ev_response_created("resp-windows-unified-deny-read"), ev_function_call( - unified_call_id, + call_id, "exec_command", &serde_json::to_string(&unified_args)?, ), @@ -522,32 +506,27 @@ async fn windows_elevated_shell_and_unified_exec_enforce_managed_deny_reads() -> .submit_with_permission_profile("read the sandbox fixtures", permission_profile) .await?; - for (tool_name, call_id) in [ - ("shell_command", shell_call_id), - ("exec_command", unified_call_id), - ] { - let output = harness.function_call_stdout(call_id).await; - assert!( - output.contains("GLOB-DENIED"), - "{tool_name} should reject glob-denied reads: {output:?}" - ); - assert!( - output.contains("EXACT-DENIED"), - "{tool_name} should reject exact-path-denied reads: {output:?}" - ); - assert!( - output.contains("public ok"), - "{tool_name} should preserve allowed reads: {output:?}" - ); - assert!( - !output.contains("GLOB-READ") && !output.contains("glob secret"), - "{tool_name} leaked glob-denied file contents: {output:?}" - ); - assert!( - !output.contains("EXACT-READ") && !output.contains("exact secret"), - "{tool_name} leaked exact-path-denied file contents: {output:?}" - ); - } + let output = harness.function_call_stdout(call_id).await; + assert!( + output.contains("GLOB-DENIED"), + "exec_command should reject glob-denied reads: {output:?}" + ); + assert!( + output.contains("EXACT-DENIED"), + "exec_command should reject exact-path-denied reads: {output:?}" + ); + assert!( + output.contains("public ok"), + "exec_command should preserve allowed reads: {output:?}" + ); + assert!( + !output.contains("GLOB-READ") && !output.contains("glob secret"), + "exec_command leaked glob-denied file contents: {output:?}" + ); + assert!( + !output.contains("EXACT-READ") && !output.contains("exact secret"), + "exec_command leaked exact-path-denied file contents: {output:?}" + ); Ok(()) } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs index 8bf8c64bec..b78996dffe 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension.rs @@ -56,7 +56,7 @@ fn should_classify_tool( ) -> bool { if sandboxed_exec_commands || !tool_name.is_default_namespace() - || !matches!(tool_name.name.as_str(), "exec_command" | "shell_command") + || tool_name.name != "exec_command" { return true; } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs index 4af0c635a5..0f8fdcffad 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs @@ -271,25 +271,23 @@ async fn sandboxed_shell_classification_respects_review_scope() -> Result<()> { arguments: r#"{"cmd":"pwd","sandbox_permissions":"require_escalated"}"#.to_owned(), }; - for tool_name in ["exec_command", "shell_command"] { - let tool_name = ToolName::plain(tool_name); - assert!(!should_classify_tool( - &tool_name, &sandboxed, /*sandboxed_exec_commands*/ false - )); - assert!(!should_classify_tool( - &tool_name, - &additional_permissions, - /*sandboxed_exec_commands*/ false - )); - assert!(should_classify_tool( - &tool_name, - &unsandboxed, - /*sandboxed_exec_commands*/ false - )); - assert!(should_classify_tool( - &tool_name, &sandboxed, /*sandboxed_exec_commands*/ true - )); - } + let tool_name = ToolName::plain("exec_command"); + assert!(!should_classify_tool( + &tool_name, &sandboxed, /*sandboxed_exec_commands*/ false + )); + assert!(!should_classify_tool( + &tool_name, + &additional_permissions, + /*sandboxed_exec_commands*/ false + )); + assert!(should_classify_tool( + &tool_name, + &unsandboxed, + /*sandboxed_exec_commands*/ false + )); + assert!(should_classify_tool( + &tool_name, &sandboxed, /*sandboxed_exec_commands*/ true + )); assert!(should_classify_tool( &ToolName::plain("read_file"), &sandboxed, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 71abcb1bd9..7d0a68ad19 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -862,8 +862,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::UnifiedExecZshFork, key: "unified_exec_zsh_fork", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Removed, + default_enabled: true, }, FeatureSpec { id: Feature::ShellSnapshot, diff --git a/codex-rs/mcp-server/tests/common/lib.rs b/codex-rs/mcp-server/tests/common/lib.rs index 57c0ce825d..503e753118 100644 --- a/codex-rs/mcp-server/tests/common/lib.rs +++ b/codex-rs/mcp-server/tests/common/lib.rs @@ -10,5 +10,5 @@ pub use core_test_support::format_with_current_shell_non_login; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_responses_server; pub use responses::create_apply_patch_sse_response; +pub use responses::create_command_execution_sse_response; pub use responses::create_final_assistant_message_sse_response; -pub use responses::create_shell_command_sse_response; diff --git a/codex-rs/mcp-server/tests/common/responses.rs b/codex-rs/mcp-server/tests/common/responses.rs index 2b4d78ce18..099919a838 100644 --- a/codex-rs/mcp-server/tests/common/responses.rs +++ b/codex-rs/mcp-server/tests/common/responses.rs @@ -3,24 +3,27 @@ use std::path::Path; use core_test_support::responses; use serde_json::json; -pub fn create_shell_command_sse_response( +pub fn create_command_execution_sse_response( command: Vec, workdir: Option<&Path>, timeout_ms: Option, call_id: &str, ) -> anyhow::Result { let command_str = shlex::try_join(command.iter().map(String::as_str))?; - let arguments = serde_json::to_string(&json!({ - "command": command_str, + let mut tool_call_arguments = json!({ + "cmd": command_str, "workdir": workdir.map(|w| w.to_string_lossy()), - "timeout_ms": timeout_ms, "sandbox_permissions": "require_escalated", "justification": "Test approval request.", - }))?; + }); + if let Some(timeout_ms) = timeout_ms { + tool_call_arguments["yield_time_ms"] = json!(timeout_ms); + } + let arguments = serde_json::to_string(&tool_call_arguments)?; let response_id = format!("resp-{call_id}"); Ok(responses::sse(vec![ responses::ev_response_created(&response_id), - responses::ev_function_call(call_id, "shell_command", &arguments), + responses::ev_function_call(call_id, "exec_command", &arguments), responses::ev_completed(&response_id), ])) } @@ -39,11 +42,11 @@ pub fn create_apply_patch_sse_response( call_id: &str, ) -> anyhow::Result { let command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF"); - let arguments = serde_json::to_string(&json!({ "command": command }))?; + let arguments = serde_json::to_string(&json!({ "cmd": command }))?; let response_id = format!("resp-{call_id}"); Ok(responses::sse(vec![ responses::ev_response_created(&response_id), - responses::ev_function_call(call_id, "shell_command", &arguments), + responses::ev_function_call(call_id, "exec_command", &arguments), responses::ev_completed(&response_id), ])) } diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index dcc6bf8b0c..d58bdd7a6e 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -31,20 +31,19 @@ use wiremock::matchers::path; use core_test_support::skip_if_no_network; use mcp_test_support::McpProcess; use mcp_test_support::create_apply_patch_sse_response; +use mcp_test_support::create_command_execution_sse_response; use mcp_test_support::create_final_assistant_message_sse_response; use mcp_test_support::create_mock_responses_server; -use mcp_test_support::create_shell_command_sse_response; use mcp_test_support::format_with_current_shell; // Windows CI can spend tens of seconds in session startup before the first // mock model request is sent. const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); -/// Test that a shell command that is not on the "trusted" list triggers an -/// elicitation request to the MCP and that sending the approval runs the -/// command, as expected. +/// Test that an explicitly escalated exec command triggers MCP elicitation and +/// that approving the request runs the command. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_shell_command_approval_triggers_elicitation() { +async fn test_exec_command_approval_triggers_elicitation() { if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { println!( "Skipping test because it cannot execute when network is disabled in a Codex sandbox." @@ -54,14 +53,13 @@ async fn test_shell_command_approval_triggers_elicitation() { // Apparently `#[tokio::test]` must return `()`, so we create a helper // function that returns `Result` so we can use `?` in favor of `unwrap`. - shell_command_approval_triggers_elicitation() + exec_command_approval_triggers_elicitation() .await - .expect("shell command approval should trigger elicitation"); + .expect("exec command approval should trigger elicitation"); } -async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> { - // Use a simple, untrusted command that creates a file so we can - // observe a side-effect. +async fn exec_command_approval_triggers_elicitation() -> anyhow::Result<()> { + // Use a command that creates a file so we can observe its side effect. let workdir_for_shell_function_call = TempDir::new()?; let created_filename = "created_by_shell_tool.txt"; let created_file = workdir_for_shell_function_call @@ -95,7 +93,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> { server: _server, dir: _dir, } = create_mcp_process(vec![ - create_shell_command_sse_response( + create_command_execution_sse_response( shell_command.clone(), Some(workdir_for_shell_function_call.path()), Some(timeout_ms), @@ -111,6 +109,11 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> { let codex_request_id = mcp_process .send_codex_tool_call(CodexToolCallParam { prompt: "run `git init`".to_string(), + // Exercise MCP elicitation even when the surrounding environment enables auto-review. + config: Some(HashMap::from([ + ("approvals_reviewer".to_string(), json!("user")), + ("features.guardian_approval".to_string(), json!(false)), + ])), ..Default::default() }) .await?; @@ -646,7 +649,7 @@ async fn create_mcp_process(responses: Vec) -> anyhow::Result } /// Create a Codex config that uses the mock server as the model provider. -/// The shell command explicitly requests escalation so that we exercise the +/// The command explicitly requests escalation so that we exercise the /// elicitation code path. fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); diff --git a/codex-rs/models-manager/models.json b/codex-rs/models-manager/models.json index 413d8eeb74..2e2dad44cd 100644 --- a/codex-rs/models-manager/models.json +++ b/codex-rs/models-manager/models.json @@ -58,7 +58,7 @@ "description": "Maximum reasoning with automatic task delegation" } ], - "shell_type": "shell_command", + "shell_type": "unified_exec", "visibility": "list", "minimal_client_version": "0.144.0", "supported_in_api": true, @@ -172,7 +172,7 @@ "description": "Maximum reasoning with automatic task delegation" } ], - "shell_type": "shell_command", + "shell_type": "unified_exec", "visibility": "list", "minimal_client_version": "0.144.0", "supported_in_api": true, @@ -280,7 +280,7 @@ "description": "Maximum reasoning depth for the hardest problems" } ], - "shell_type": "shell_command", + "shell_type": "unified_exec", "visibility": "list", "minimal_client_version": "0.144.0", "supported_in_api": true, @@ -384,7 +384,7 @@ "description": "Extra high reasoning depth for complex problems" } ], - "shell_type": "shell_command", + "shell_type": "unified_exec", "visibility": "list", "minimal_client_version": "0.124.0", "supported_in_api": true, @@ -490,7 +490,7 @@ "description": "Extra high reasoning depth for complex problems" } ], - "shell_type": "shell_command", + "shell_type": "unified_exec", "visibility": "hide", "minimal_client_version": "0.98.0", "supported_in_api": true, @@ -594,7 +594,7 @@ "description": "Extra high reasoning depth for complex problems" } ], - "shell_type": "shell_command", + "shell_type": "unified_exec", "visibility": "hide", "minimal_client_version": "0.98.0", "supported_in_api": true, @@ -693,7 +693,7 @@ "description": "Extra high reasoning for complex problems" } ], - "shell_type": "shell_command", + "shell_type": "unified_exec", "visibility": "list", "minimal_client_version": "0.0.1", "supported_in_api": true, @@ -789,7 +789,7 @@ "description": "Extra high reasoning depth for complex problems" } ], - "shell_type": "shell_command", + "shell_type": "unified_exec", "visibility": "hide", "minimal_client_version": "0.98.0", "supported_in_api": true, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index ba23dc2fa2..18b4bee9e8 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -1971,32 +1971,6 @@ pub struct SearchToolCallParams { pub limit: Option, } -/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the -/// `arguments` field should deserialize to this struct. -#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -pub struct ShellCommandToolCallParams { - pub command: String, - pub workdir: Option, - - /// Whether to run the shell with login shell semantics - #[serde(skip_serializing_if = "Option::is_none")] - pub login: Option, - /// This is the maximum time in milliseconds that the command is allowed to run. - #[serde(alias = "timeout")] - pub timeout_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub sandbox_permissions: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub prefix_rule: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub additional_permissions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub justification: Option, -} - /// Responses API compatible content items that can be returned by a tool call. /// This is a subset of ContentItem with the types we support as function call outputs. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index c758f5f4a5..30c9c3b118 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -299,9 +299,9 @@ pub enum ModelVisibility { pub enum ConfigShellToolType { Default, Local, + #[serde(alias = "shell_command")] UnifiedExec, Disabled, - ShellCommand, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, TS, JsonSchema)] @@ -886,6 +886,18 @@ mod tests { use serde_json::from_str; use serde_json::to_string; + #[test] + fn legacy_shell_model_metadata_deserializes_as_unified_exec() { + assert_eq!( + from_str::("\"shell_command\"").expect("legacy shell type"), + ConfigShellToolType::UnifiedExec + ); + assert_eq!( + to_string(&ConfigShellToolType::UnifiedExec).expect("serialize unified shell type"), + "\"unified_exec\"" + ); + } + fn test_model(spec: Option) -> ModelInfo { ModelInfo { slug: "test-model".to_string(), @@ -893,7 +905,7 @@ mod tests { description: None, default_reasoning_level: None, supported_reasoning_levels: vec![], - shell_type: ConfigShellToolType::ShellCommand, + shell_type: ConfigShellToolType::UnifiedExec, visibility: ModelVisibility::List, supported_in_api: true, priority: 1, @@ -1530,7 +1542,7 @@ mod tests { "display_name": "Test Model", "description": null, "supported_reasoning_levels": [], - "shell_type": "shell_command", + "shell_type": "unified_exec", "visibility": "list", "supported_in_api": true, "priority": 1, diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index 55b28f4939..e621bb830b 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -17,7 +17,6 @@ codex-extension-items = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-output-truncation = { workspace = true } -codex-utils-pty = { workspace = true } codex-utils-string = { workspace = true } jsonptr = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 6f90e65c99..879318f80d 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -71,14 +71,12 @@ pub use tool_call::ToolCall; pub use tool_call::ToolEnvironment; pub use tool_call::TurnItemEmissionFuture; pub use tool_call::TurnItemEmitter; -pub use tool_config::ShellCommandBackendConfig; pub use tool_config::ToolEnvironmentMode; pub use tool_config::ToolUserShellType; pub use tool_config::UnifiedExecFeatureMode; pub use tool_config::UnifiedExecShellMode; pub use tool_config::ZshForkConfig; pub use tool_config::request_user_input_available_modes; -pub use tool_config::shell_command_backend_for_features; pub use tool_config::shell_type_for_model_and_features; pub use tool_config::unified_exec_feature_mode_for_features; pub use tool_definition::ToolDefinition; diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 990d4a3c58..1f525d4181 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -7,20 +7,10 @@ use codex_protocol::openai_models::ModelInfo; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::PathBuf; -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum ShellCommandBackendConfig { - Classic, - ZshFork, -} - #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum UnifiedExecFeatureMode { /// Unified exec should not be selected by this feature set. /// - /// This includes standalone `shell_zsh_fork`: until - /// `unified_exec_zsh_fork` is enabled too, `shell_zsh_fork` keeps using - /// the shell command backend instead of silently opting unified exec into - /// zsh-fork interception. Disabled, Direct, ZshFork, @@ -46,24 +36,12 @@ pub fn request_user_input_available_modes(features: &Features) -> Vec .collect() } -pub fn shell_command_backend_for_features(features: &Features) -> ShellCommandBackendConfig { - if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { - ShellCommandBackendConfig::ZshFork - } else { - ShellCommandBackendConfig::Classic - } -} - /// Returns the unified-exec mode requested by feature policy, before runtime /// session inputs such as platform, user shell, and zsh-fork binary paths are /// resolved. /// -/// `unified_exec_zsh_fork` is only a composition gate. It does not enable -/// either underlying shell mode on its own, so disabling `unified_exec` or -/// `shell_zsh_fork` keeps those features independently off. This lets -/// enterprise deployments opt into, or out of, unified exec and zsh-fork -/// behavior separately; otherwise enabling the composition flag would silently -/// activate a shell backend that the configured feature set left disabled. +/// Disabling unified exec keeps command execution disabled. The legacy +/// composition flag can still disable zsh-fork interception independently. pub fn unified_exec_feature_mode_for_features(features: &Features) -> UnifiedExecFeatureMode { if !features.enabled(Feature::ShellTool) || !features.enabled(Feature::UnifiedExec) { UnifiedExecFeatureMode::Disabled @@ -71,7 +49,7 @@ pub fn unified_exec_feature_mode_for_features(features: &Features) -> UnifiedExe if features.enabled(Feature::UnifiedExecZshFork) { UnifiedExecFeatureMode::ZshFork } else { - UnifiedExecFeatureMode::Disabled + UnifiedExecFeatureMode::Direct } } else { UnifiedExecFeatureMode::Direct @@ -82,36 +60,13 @@ pub fn shell_type_for_model_and_features( model_info: &ModelInfo, features: &Features, ) -> ConfigShellToolType { - let unified_exec_feature_mode = unified_exec_feature_mode_for_features(features); - let unified_exec_disabled = - matches!(unified_exec_feature_mode, UnifiedExecFeatureMode::Disabled); - let model_shell_type = match model_info.shell_type { - ConfigShellToolType::UnifiedExec if unified_exec_disabled => { - ConfigShellToolType::ShellCommand - } - ConfigShellToolType::Default | ConfigShellToolType::Local => { - ConfigShellToolType::ShellCommand - } - other => other, - }; - let shell_command_type = match shell_command_backend_for_features(features) { - ShellCommandBackendConfig::Classic => model_shell_type, - ShellCommandBackendConfig::ZshFork => ConfigShellToolType::ShellCommand, - }; - - if !features.enabled(Feature::ShellTool) { + if !features.enabled(Feature::ShellTool) + || !features.enabled(Feature::UnifiedExec) + || matches!(model_info.shell_type, ConfigShellToolType::Disabled) + { ConfigShellToolType::Disabled } else { - match unified_exec_feature_mode { - UnifiedExecFeatureMode::Disabled => shell_command_type, - UnifiedExecFeatureMode::Direct | UnifiedExecFeatureMode::ZshFork => { - if codex_utils_pty::conpty_supported() { - ConfigShellToolType::UnifiedExec - } else { - ConfigShellToolType::ShellCommand - } - } - } + ConfigShellToolType::UnifiedExec } } diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 5bcc11fe52..e9ab1ed842 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -69,59 +69,31 @@ fn shell_features() -> Features { fn shell_type_is_derived_from_model_and_feature_gates() { let model = model_with_shell_type(ConfigShellToolType::UnifiedExec); let mut features = shell_features(); - assert_eq!( - shell_type_for_model_and_features(&model, &features), - ConfigShellToolType::ShellCommand - ); - features.enable(Feature::UnifiedExec); - let expected_unified_exec = if codex_utils_pty::conpty_supported() { + assert_eq!( + shell_type_for_model_and_features(&model, &features), ConfigShellToolType::UnifiedExec - } else { - ConfigShellToolType::ShellCommand - }; - assert_eq!( - shell_type_for_model_and_features(&model, &features), - expected_unified_exec ); - - features.enable(Feature::ShellZshFork); - assert_eq!( - shell_type_for_model_and_features(&model, &features), - ConfigShellToolType::ShellCommand - ); - - features.enable(Feature::UnifiedExecZshFork); - assert_eq!( - shell_type_for_model_and_features(&model, &features), - expected_unified_exec - ); - features.disable(Feature::ShellTool); assert_eq!( shell_type_for_model_and_features(&model, &features), ConfigShellToolType::Disabled ); + + features.enable(Feature::ShellTool); + features.disable(Feature::UnifiedExec); + assert_eq!( + shell_type_for_model_and_features(&model, &features), + ConfigShellToolType::Disabled + ); } #[test] -fn shell_command_backend_requires_both_shell_tool_and_zsh_fork() { - let mut features = shell_features(); +fn shell_type_respects_disabled_model_capability() { + let model = model_with_shell_type(ConfigShellToolType::Disabled); assert_eq!( - shell_command_backend_for_features(&features), - ShellCommandBackendConfig::Classic - ); - - features.enable(Feature::ShellZshFork); - assert_eq!( - shell_command_backend_for_features(&features), - ShellCommandBackendConfig::ZshFork - ); - - features.disable(Feature::ShellTool); - assert_eq!( - shell_command_backend_for_features(&features), - ShellCommandBackendConfig::Classic + shell_type_for_model_and_features(&model, &shell_features()), + ConfigShellToolType::Disabled ); } @@ -149,7 +121,7 @@ fn unified_exec_feature_mode_follows_composition_dependencies() { features.disable(Feature::UnifiedExecZshFork); assert_eq!( unified_exec_feature_mode_for_features(&features), - UnifiedExecFeatureMode::Disabled + UnifiedExecFeatureMode::Direct ); features.enable(Feature::UnifiedExecZshFork); diff --git a/scripts/mock_responses_websocket_server.py b/scripts/mock_responses_websocket_server.py index 6d41df8a4a..f018bf673d 100644 --- a/scripts/mock_responses_websocket_server.py +++ b/scripts/mock_responses_websocket_server.py @@ -14,9 +14,9 @@ HOST = "127.0.0.1" DEFAULT_PORT = 8765 PATH = "/v1/responses" -CALL_ID = "shell-command-call" -FUNCTION_NAME = "shell_command" -FUNCTION_ARGS_JSON = json.dumps({"command": "echo websocket"}, separators=(",", ":")) +CALL_ID = "exec-command-call" +FUNCTION_NAME = "exec_command" +FUNCTION_ARGS_JSON = json.dumps({"cmd": "echo websocket"}, separators=(",", ":")) ASSISTANT_TEXT = "done" diff --git a/sdk/typescript/tests/abort.test.ts b/sdk/typescript/tests/abort.test.ts index ca93b1e89d..d56e2e85a9 100644 --- a/sdk/typescript/tests/abort.test.ts +++ b/sdk/typescript/tests/abort.test.ts @@ -4,7 +4,7 @@ import { assistantMessage, responseCompleted, responseStarted, - shell_call as shellCall, + exec_command_call as execCommandCall, sse, SseResponseBody, startResponsesTestProxy, @@ -13,7 +13,7 @@ import { createMockClient } from "./testCodex"; function* infiniteShellCall(): Generator { while (true) { - yield sse(responseStarted(), shellCall(), responseCompleted()); + yield sse(responseStarted(), execCommandCall(), responseCompleted()); } } diff --git a/sdk/typescript/tests/responsesProxy.ts b/sdk/typescript/tests/responsesProxy.ts index 012cbf2320..eb76742dbc 100644 --- a/sdk/typescript/tests/responsesProxy.ts +++ b/sdk/typescript/tests/responsesProxy.ts @@ -180,16 +180,16 @@ export function assistantMessage(text: string, itemId: string = DEFAULT_MESSAGE_ }; } -export function shell_call(): SseEvent { +export function exec_command_call(): SseEvent { return { type: "response.output_item.done", item: { type: "function_call", call_id: `call_id${Math.random().toString(36).slice(2)}`, - name: "shell_command", + name: "exec_command", arguments: JSON.stringify({ - command: "echo 'Hello, world!'", - timeout_ms: 100, + cmd: "echo 'Hello, world!'", + yield_time_ms: 100, }), }, };