From 624ccf794703e2d84e748fc3ef547d6191a8c0a4 Mon Sep 17 00:00:00 2001 From: jif Date: Fri, 11 Sep 2026 12:26:38 +0000 Subject: [PATCH] Test approved command execution with managed unified exec disabled (#44814) ## What changed Add an app-server integration test for `requirements.toml` with `unified_exec = false` and `shell_tool = true`. Verify that: - The model receives `exec_command` without `write_stdin`. - Command approval carries the expected thread, item, and environment IDs. - Accepting approval completes the command and turn successfully. - The model receives command output without a running session ID. GitOrigin-RevId: 67977c20b9b206e210807d0ce1e305259d25b7ba --- .../v2/config_requirements_exec_tests.rs | 162 ++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 2 + 2 files changed, 164 insertions(+) create mode 100644 codex-rs/app-server/tests/suite/v2/config_requirements_exec_tests.rs diff --git a/codex-rs/app-server/tests/suite/v2/config_requirements_exec_tests.rs b/codex-rs/app-server/tests/suite/v2/config_requirements_exec_tests.rs new file mode 100644 index 0000000000..8a0c8fcf2b --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/config_requirements_exec_tests.rs @@ -0,0 +1,162 @@ +//! Managed unified-exec restrictions must retain approved, completion-only command execution. + +use anyhow::Context; +use anyhow::Result; +use app_test_support::MockResponsesConfig; +use app_test_support::TestAppServer; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UserInput; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use core_test_support::skip_if_wine_exec; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn managed_unified_exec_disabled_preserves_approved_command_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "non-TTY PowerShell execution and command approval routing are unsupported under Wine" + ); + + let read_timeout = Duration::from_secs(/*secs*/ 60); + let server = responses::start_mock_server().await; + let home = TempDir::new()?; + std::fs::write( + home.path().join("requirements.toml"), + r#" +[features] +unified_exec = false +shell_tool = true + +[[rules.prefix_rules]] +pattern = [{ token = "echo" }] +decision = "prompt" +"#, + )?; + MockResponsesConfig::new(&server.uri()) + .with_approval_policy("on-request") + .with_sandbox_mode("workspace-write") + .write(home.path())?; + + let call_id = "managed-exec"; + let command = "echo managed-exec-ok"; + let command_response = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("command"), + responses::ev_function_call( + call_id, + "exec_command", + &json!({"cmd": command, "timeout_ms": 10_000}).to_string(), + ), + responses::ev_completed("command"), + ]), + ) + .await; + let continuation = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_assistant_message("done", "Command completed"), + responses::ev_completed("completed"), + ]), + ) + .await; + + let mut app = TestAppServer::builder() + .with_codex_home(home.path()) + .build_initialized_with_timeout(read_timeout) + .await?; + let environment_id = app.auto_env_params()?.environment_id; + let thread = app.start_thread(ThreadStartParams::default()).await?.thread; + let request_id = app + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "Run the command".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _: TurnStartResponse = timeout(read_timeout, app.read_response(request_id)).await??; + + let request = timeout(read_timeout, app.read_stream_until_request_message()).await??; + let ServerRequest::CommandExecutionRequestApproval { request_id, params } = request else { + anyhow::bail!("expected command approval, got {request:?}"); + }; + assert_eq!( + (params.thread_id, params.item_id, params.environment_id), + (thread.id.clone(), call_id.to_string(), Some(environment_id)), + ); + + let body = command_response.single_request().body_json(); + let command_tools = body["tools"] + .as_array() + .context("model request must advertise tools")? + .iter() + .filter_map(|tool| tool["name"].as_str()) + .filter(|name| matches!(*name, "exec_command" | "write_stdin")) + .collect::>(); + assert_eq!(command_tools, vec!["exec_command"]); + + app.send_response( + request_id, + serde_json::to_value(CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::Accept, + })?, + ) + .await?; + timeout(read_timeout, async { + loop { + let completed: ItemCompletedNotification = + app.read_notification("item/completed").await?; + if let ThreadItem::CommandExecution { + id, + status, + exit_code, + .. + } = completed.item + && id == call_id + { + assert_eq!( + (status, exit_code), + (CommandExecutionStatus::Completed, Some(0)) + ); + break; + } + } + anyhow::Ok(()) + }) + .await??; + let completed: TurnCompletedNotification = + timeout(read_timeout, app.read_notification("turn/completed")).await??; + assert_eq!(completed.turn.status, TurnStatus::Completed); + + // Check the model continuation because early stdout can precede event subscription. + let output = continuation + .single_request() + .function_call_output_text(call_id) + .context("model must receive command output")?; + assert!(output.contains("managed-exec-ok"), "{output}"); + assert!( + !output.contains("Process running with session ID"), + "{output}" + ); + app.shutdown_gracefully().await?; + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 402b589f5b..0c79256813 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -18,6 +18,8 @@ mod config_model_provider_requirements; mod config_requirements_application; #[path = "config_requirements_browser_use_tests.rs"] mod config_requirements_browser_use; +#[path = "config_requirements_exec_tests.rs"] +mod config_requirements_exec; mod config_requirements_in_app_browser; mod config_rpc; #[cfg(unix)]