From 7c0af91bd2806167a20edd65da4f602d14709876 Mon Sep 17 00:00:00 2001 From: celia-oai Date: Fri, 20 Feb 2026 17:02:33 -0800 Subject: [PATCH] changes --- .../tests/suite/v2/turn_start_zsh_fork.rs | 166 ++++++++++++++++++ codex-rs/core/src/zsh_exec_bridge/mod.rs | 18 +- 2 files changed, 182 insertions(+), 2 deletions(-) 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 4233ce6494..5115364c99 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 @@ -419,6 +419,172 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_shell_zsh_fork_subcommand_escalated_requests_approval_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let Some(zsh_path) = find_test_zsh_path() else { + eprintln!("skipping zsh fork subcommand escalated test: no zsh executable found"); + return Ok(()); + }; + if !supports_exec_wrapper_intercept(&zsh_path) { + eprintln!( + "skipping zsh fork subcommand escalated test: zsh does not support EXEC_WRAPPER intercepts ({})", + zsh_path.display() + ); + return Ok(()); + } + eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); + + let tool_call_arguments = serde_json::to_string(&serde_json::json!({ + "command": "touch zsh_fork_escalated.txt", + "workdir": serde_json::Value::Null, + "timeout_ms": 5000, + "sandbox_permissions": "require_escalated", + }))?; + let response = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call( + "call-zsh-fork-subcommand-escalated", + "shell_command", + &tool_call_arguments, + ), + responses::ev_completed("resp-1"), + ]); + let server = create_mock_responses_server_sequence(vec![response]).await; + create_config_toml( + &codex_home, + &server.uri(), + "on-request", + &BTreeMap::from([ + (Feature::ShellZshFork, true), + (Feature::UnifiedExec, false), + (Feature::ShellSnapshot, false), + ]), + &zsh_path, + )?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + cwd: Some(workspace.to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run touch".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::OnRequest), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { + access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, + }), + model: Some("mock-model".to_string()), + effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), + summary: Some(codex_core::protocol_config_types::ReasoningSummary::Auto), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + + let mut saw_subcommand_approval = false; + for _ in 0..3 { + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req + else { + panic!("expected CommandExecutionRequestApproval request"); + }; + assert_eq!(params.item_id, "call-zsh-fork-subcommand-escalated"); + assert_eq!(params.thread_id, thread.id); + if params.approval_id.is_some() { + saw_subcommand_approval = true; + } + mcp.send_response( + request_id, + serde_json::to_value(CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::Accept, + })?, + ) + .await?; + if saw_subcommand_approval { + break; + } + } + assert!( + saw_subcommand_approval, + "expected zsh subcommand approval request with approval_id" + ); + + let parent_completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let completed_notif = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + completed_notif + .params + .clone() + .expect("item/completed params"), + )?; + if let ThreadItem::CommandExecution { id, .. } = &completed.item + && id == "call-zsh-fork-subcommand-escalated" + { + return Ok::(completed.item); + } + } + }) + .await??; + let ThreadItem::CommandExecution { id, status, .. } = parent_completed_command_execution else { + unreachable!("loop ensures we break on parent command execution item"); + }; + assert_eq!(id, "call-zsh-fork-subcommand-escalated"); + assert_eq!(status, CommandExecutionStatus::Completed); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert!(workspace.join("zsh_fork_escalated.txt").is_file()); + + Ok(()) +} + #[tokio::test] async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/src/zsh_exec_bridge/mod.rs b/codex-rs/core/src/zsh_exec_bridge/mod.rs index c55a6ccaa1..24f84d4412 100644 --- a/codex-rs/core/src/zsh_exec_bridge/mod.rs +++ b/codex-rs/core/src/zsh_exec_bridge/mod.rs @@ -284,7 +284,14 @@ impl ZshExecBridge { ToolError::Rejected(format!("failed to accept wrapper request: {err}")) })?; if self - .handle_wrapper_request(stream, req.justification.clone(), session, turn, call_id) + .handle_wrapper_request( + stream, + req.justification.clone(), + req.sandbox_permissions, + session, + turn, + call_id, + ) .await? { user_rejected = true; @@ -333,6 +340,7 @@ impl ZshExecBridge { &self, mut stream: UnixStream, approval_reason: Option, + sandbox_permissions: SandboxPermissions, session: &crate::codex::Session, turn: &crate::codex::TurnContext, call_id: &str, @@ -386,6 +394,12 @@ impl ZshExecBridge { extend_sandbox_policy(&turn.sandbox_policy, skill_permissions.sandbox_policy.get()) }, ); + tracing::debug!( + ?effective_sandbox_policy, + ?matched_skill_permissions, + ?sandbox_permissions, + "zsh exec bridge resolved sandbox policy for wrapper request" + ); // Ask exec policy whether this command can run, is forbidden, or needs approval. let exec_approval_requirement = session @@ -396,7 +410,7 @@ impl ZshExecBridge { command: &command_for_execpolicy, approval_policy: turn.approval_policy, sandbox_policy: &effective_sandbox_policy, - sandbox_permissions: SandboxPermissions::UseDefault, + sandbox_permissions, prefix_rule: None, }, &overlay_prompt_prefixes,