11use std:: path:: Path ;
22
3+ use codex_core:: config_types:: ReasoningEffort ;
4+ use codex_core:: config_types:: ReasoningSummary ;
5+ use codex_core:: protocol:: AskForApproval ;
6+ use codex_core:: protocol:: SandboxPolicy ;
37use codex_core:: spawn:: CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR ;
48use codex_mcp_server:: wire_format:: AddConversationListenerParams ;
59use codex_mcp_server:: wire_format:: AddConversationSubscriptionResponse ;
10+ use codex_mcp_server:: wire_format:: EXEC_COMMAND_APPROVAL_METHOD ;
611use codex_mcp_server:: wire_format:: NewConversationParams ;
712use codex_mcp_server:: wire_format:: NewConversationResponse ;
813use codex_mcp_server:: wire_format:: RemoveConversationListenerParams ;
914use codex_mcp_server:: wire_format:: RemoveConversationSubscriptionResponse ;
1015use codex_mcp_server:: wire_format:: SendUserMessageParams ;
1116use codex_mcp_server:: wire_format:: SendUserMessageResponse ;
17+ use codex_mcp_server:: wire_format:: SendUserTurnParams ;
18+ use codex_mcp_server:: wire_format:: SendUserTurnResponse ;
1219use mcp_test_support:: McpProcess ;
1320use mcp_test_support:: create_final_assistant_message_sse_response;
1421use mcp_test_support:: create_mock_chat_completions_server;
@@ -167,6 +174,184 @@ fn to_response<T: DeserializeOwned>(response: JSONRPCResponse) -> anyhow::Result
167174 Ok ( codex_response)
168175}
169176
177+ #[ tokio:: test( flavor = "multi_thread" , worker_threads = 4 ) ]
178+ async fn test_send_user_turn_changes_approval_policy_behavior ( ) {
179+ if env:: var ( CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR ) . is_ok ( ) {
180+ println ! (
181+ "Skipping test because it cannot execute when network is disabled in a Codex sandbox."
182+ ) ;
183+ return ;
184+ }
185+
186+ let tmp = TempDir :: new ( ) . expect ( "tmp dir" ) ;
187+ let codex_home = tmp. path ( ) . join ( "codex_home" ) ;
188+ std:: fs:: create_dir ( & codex_home) . expect ( "create codex home dir" ) ;
189+ let working_directory = tmp. path ( ) . join ( "workdir" ) ;
190+ std:: fs:: create_dir ( & working_directory) . expect ( "create working directory" ) ;
191+
192+ // Mock server will request a python shell call for the first and second turn, then finish.
193+ let responses = vec ! [
194+ create_shell_sse_response(
195+ vec![
196+ "python3" . to_string( ) ,
197+ "-c" . to_string( ) ,
198+ "print(42)" . to_string( ) ,
199+ ] ,
200+ Some ( & working_directory) ,
201+ Some ( 5000 ) ,
202+ "call1" ,
203+ )
204+ . expect( "create first shell sse response" ) ,
205+ create_final_assistant_message_sse_response( "done 1" )
206+ . expect( "create final assistant message 1" ) ,
207+ create_shell_sse_response(
208+ vec![
209+ "python3" . to_string( ) ,
210+ "-c" . to_string( ) ,
211+ "print(42)" . to_string( ) ,
212+ ] ,
213+ Some ( & working_directory) ,
214+ Some ( 5000 ) ,
215+ "call2" ,
216+ )
217+ . expect( "create second shell sse response" ) ,
218+ create_final_assistant_message_sse_response( "done 2" )
219+ . expect( "create final assistant message 2" ) ,
220+ ] ;
221+ let server = create_mock_chat_completions_server ( responses) . await ;
222+ create_config_toml ( & codex_home, & server. uri ( ) ) . expect ( "write config" ) ;
223+
224+ // Start MCP server and initialize.
225+ let mut mcp = McpProcess :: new ( & codex_home) . await . expect ( "spawn mcp" ) ;
226+ timeout ( DEFAULT_READ_TIMEOUT , mcp. initialize ( ) )
227+ . await
228+ . expect ( "init timeout" )
229+ . expect ( "init error" ) ;
230+
231+ // 1) Start conversation with approval_policy=untrusted
232+ let new_conv_id = mcp
233+ . send_new_conversation_request ( NewConversationParams {
234+ cwd : Some ( working_directory. to_string_lossy ( ) . into_owned ( ) ) ,
235+ ..Default :: default ( )
236+ } )
237+ . await
238+ . expect ( "send newConversation" ) ;
239+ let new_conv_resp: JSONRPCResponse = timeout (
240+ DEFAULT_READ_TIMEOUT ,
241+ mcp. read_stream_until_response_message ( RequestId :: Integer ( new_conv_id) ) ,
242+ )
243+ . await
244+ . expect ( "newConversation timeout" )
245+ . expect ( "newConversation resp" ) ;
246+ let NewConversationResponse {
247+ conversation_id, ..
248+ } = to_response :: < NewConversationResponse > ( new_conv_resp)
249+ . expect ( "deserialize newConversation response" ) ;
250+
251+ // 2) addConversationListener
252+ let add_listener_id = mcp
253+ . send_add_conversation_listener_request ( AddConversationListenerParams { conversation_id } )
254+ . await
255+ . expect ( "send addConversationListener" ) ;
256+ let _: AddConversationSubscriptionResponse =
257+ to_response :: < AddConversationSubscriptionResponse > (
258+ timeout (
259+ DEFAULT_READ_TIMEOUT ,
260+ mcp. read_stream_until_response_message ( RequestId :: Integer ( add_listener_id) ) ,
261+ )
262+ . await
263+ . expect ( "addConversationListener timeout" )
264+ . expect ( "addConversationListener resp" ) ,
265+ )
266+ . expect ( "deserialize addConversationListener response" ) ;
267+
268+ // 3) sendUserMessage triggers a shell call; approval policy is Untrusted so we should get an elicitation
269+ let send_user_id = mcp
270+ . send_send_user_message_request ( SendUserMessageParams {
271+ conversation_id,
272+ items : vec ! [ codex_mcp_server:: wire_format:: InputItem :: Text {
273+ text: "run python" . to_string( ) ,
274+ } ] ,
275+ } )
276+ . await
277+ . expect ( "send sendUserMessage" ) ;
278+ let _send_user_resp: SendUserMessageResponse = to_response :: < SendUserMessageResponse > (
279+ timeout (
280+ DEFAULT_READ_TIMEOUT ,
281+ mcp. read_stream_until_response_message ( RequestId :: Integer ( send_user_id) ) ,
282+ )
283+ . await
284+ . expect ( "sendUserMessage timeout" )
285+ . expect ( "sendUserMessage resp" ) ,
286+ )
287+ . expect ( "deserialize sendUserMessage response" ) ;
288+
289+ // Expect an ExecCommandApproval request (elicitation)
290+ let request = timeout (
291+ DEFAULT_READ_TIMEOUT ,
292+ mcp. read_stream_until_request_message ( ) ,
293+ )
294+ . await
295+ . expect ( "waiting for exec approval request timeout" )
296+ . expect ( "exec approval request" ) ;
297+ assert_eq ! ( request. method, EXEC_COMMAND_APPROVAL_METHOD ) ;
298+
299+ // Approve so the first turn can complete
300+ mcp. send_response (
301+ request. id ,
302+ serde_json:: json!( { "decision" : codex_core:: protocol:: ReviewDecision :: Approved } ) ,
303+ )
304+ . await
305+ . expect ( "send approval response" ) ;
306+
307+ // Wait for first TaskComplete
308+ let _ = timeout (
309+ DEFAULT_READ_TIMEOUT ,
310+ mcp. read_stream_until_notification_message ( "codex/event/task_complete" ) ,
311+ )
312+ . await
313+ . expect ( "task_complete 1 timeout" )
314+ . expect ( "task_complete 1 notification" ) ;
315+
316+ // 4) sendUserTurn with approval_policy=never should run without elicitation
317+ let send_turn_id = mcp
318+ . send_send_user_turn_request ( SendUserTurnParams {
319+ conversation_id,
320+ items : vec ! [ codex_mcp_server:: wire_format:: InputItem :: Text {
321+ text: "run python again" . to_string( ) ,
322+ } ] ,
323+ cwd : working_directory. clone ( ) ,
324+ approval_policy : AskForApproval :: Never ,
325+ sandbox_policy : SandboxPolicy :: new_read_only_policy ( ) ,
326+ model : "mock-model" . to_string ( ) ,
327+ effort : ReasoningEffort :: Medium ,
328+ summary : ReasoningSummary :: Auto ,
329+ } )
330+ . await
331+ . expect ( "send sendUserTurn" ) ;
332+ // Acknowledge sendUserTurn
333+ let _send_turn_resp: SendUserTurnResponse = to_response :: < SendUserTurnResponse > (
334+ timeout (
335+ DEFAULT_READ_TIMEOUT ,
336+ mcp. read_stream_until_response_message ( RequestId :: Integer ( send_turn_id) ) ,
337+ )
338+ . await
339+ . expect ( "sendUserTurn timeout" )
340+ . expect ( "sendUserTurn resp" ) ,
341+ )
342+ . expect ( "deserialize sendUserTurn response" ) ;
343+
344+ // Ensure we do NOT receive an ExecCommandApproval request before the task completes.
345+ // If any Request is seen while waiting for task_complete, the helper will error and the test fails.
346+ let _ = timeout (
347+ DEFAULT_READ_TIMEOUT ,
348+ mcp. read_stream_until_notification_message ( "codex/event/task_complete" ) ,
349+ )
350+ . await
351+ . expect ( "task_complete 2 timeout" )
352+ . expect ( "task_complete 2 notification" ) ;
353+ }
354+
170355// Helper: minimal config.toml pointing at mock provider.
171356fn create_config_toml ( codex_home : & Path , server_uri : & str ) -> std:: io:: Result < ( ) > {
172357 let config_toml = codex_home. join ( "config.toml" ) ;
@@ -175,7 +360,7 @@ fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()
175360 format ! (
176361 r#"
177362model = "mock-model"
178- approval_policy = "never "
363+ approval_policy = "untrusted "
179364
180365model_provider = "mock_provider"
181366
0 commit comments