#![allow(clippy::unwrap_used)]#![cfg(target_os = "macos")]use anyhow::Result;use codex_core::config::Constrained;use codex_features::Feature;use codex_protocol::config_types::ApprovalsReviewer;use codex_protocol::models::FileSystemPermissions;use codex_protocol::models::PermissionProfile;use codex_protocol::permissions::NetworkSandboxPolicy;use codex_protocol::protocol::AskForApproval;use codex_protocol::protocol::EventMsg;use codex_protocol::protocol::Op;use codex_protocol::protocol::ReviewDecision;use codex_protocol::request_permissions::PermissionGrantScope;use codex_protocol::request_permissions::RequestPermissionProfile;use codex_protocol::request_permissions::RequestPermissionsResponse;use codex_protocol::user_input::UserInput;use codex_utils_absolute_path::AbsolutePathBuf;use core_test_support::responses::ev_apply_patch_custom_tool_call;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_sandbox;use core_test_support::test_codex::TestCodex;use core_test_support::test_codex::local_selections;use core_test_support::test_codex::test_codex;use core_test_support::test_codex::turn_permission_fields;use core_test_support::wait_for_event;use pretty_assertions::assert_eq;use regex_lite::Regex;use serde_json::Value;use serde_json::json;use std::fs;use std::path::Path;fn absolute_path(path: &Path) -> AbsolutePathBuf { AbsolutePathBuf::try_from(path).expect("absolute path")}fn request_permissions_tool_event( call_id: &str, reason: &str, permissions: &RequestPermissionProfile,) -> Result<Value> { let args = json!({ "reason": reason, "permissions": permissions, }); let args_str = serde_json::to_string(&args)?; Ok(ev_function_call(call_id, "request_permissions", &args_str))}fn exec_command_event(call_id: &str, command: &str) -> Result<Value> { let args = json!({ "cmd": command, "yield_time_ms": 1_000_u64, }); let args_str = serde_json::to_string(&args)?; Ok(ev_function_call(call_id, "exec_command", &args_str))}fn build_add_file_patch(patch_path: &Path, content: &str) -> String { format!( "*** Begin Patch\n*** Add File: {}\n+{}\n*** End Patch\n", patch_path.display(), content )}fn workspace_write_excluding_tmp() -> PermissionProfile { PermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Restricted, /*exclude_tmpdir_env_var*/ true, /*exclude_slash_tmp*/ true, )}fn requested_directory_write_permissions(path: &Path) -> RequestPermissionProfile { RequestPermissionProfile { file_system: Some(FileSystemPermissions::from_read_write_roots( Some(vec![]), Some(vec![absolute_path(path)]), )), ..RequestPermissionProfile::default() }}fn normalized_directory_write_permissions(path: &Path) -> Result<RequestPermissionProfile> { Ok(RequestPermissionProfile { file_system: Some(FileSystemPermissions::from_read_write_roots( Some(vec![]), Some(vec![AbsolutePathBuf::try_from(path.canonicalize()?)?]), )), ..RequestPermissionProfile::default() })}fn parse_result(item: &Value) -> (Option<i64>, String) { let output_str = item .get("output") .and_then(Value::as_str) .expect("shell output payload"); match serde_json::from_str::<Value>(output_str) { Ok(parsed) => { let exit_code = parsed["metadata"]["exit_code"].as_i64(); let stdout = parsed["output"].as_str().unwrap_or_default().to_string(); (exit_code, stdout) } Err(_) => { let structured = Regex::new(r"(?s)^Exit code:\s*(-?\d+).*?Output:\n(.*)$").unwrap(); let regex = Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap(); if let Some(captures) = structured.captures(output_str) { let exit_code = captures.get(1).unwrap().as_str().parse::<i64>().unwrap(); let output = captures.get(2).unwrap().as_str(); (Some(exit_code), output.to_string()) } else if let Some(captures) = regex.captures(output_str) { let exit_code = captures.get(1).unwrap().as_str().parse::<i64>().unwrap(); let output = captures.get(2).unwrap().as_str(); (Some(exit_code), output.to_string()) } else { (None, output_str.to_string()) } } }}async fn submit_turn( test: &TestCodex, prompt: &str, approval_policy: AskForApproval, permission_profile: PermissionProfile, approvals_reviewer: Option<ApprovalsReviewer>,) -> Result<()> { let session_model = test.session_configured.model.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, test.config.cwd.as_path()); test.codex .submit(Op::UserInput { items: vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }], final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { environments: Some(local_selections(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), permission_profile, collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { mode: codex_protocol::config_types::ModeKind::Default, settings: codex_protocol::config_types::Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() }, }) .await?; Ok(())}async fn wait_for_completion(test: &TestCodex) { wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) }) .await;}async fn expect_request_permissions_event( test: &TestCodex, expected_call_id: &str,) -> RequestPermissionProfile { let event = wait_for_event(&test.codex, |event| { matches!( event, EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_) ) }) .await; match event { EventMsg::RequestPermissions(request) => { assert_eq!(request.call_id, expected_call_id); request.permissions } EventMsg::TurnComplete(_) => panic!("expected request_permissions before completion"), other => panic!("unexpected event: {other:?}"), }}#[tokio::test(flavor = "current_thread")]#[cfg(target_os = "macos")]async fn approved_folder_write_request_permissions_unblocks_later_exec_without_sandbox_args()-> 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 = permission_profile.clone(); 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 requested_dir = tempfile::tempdir()?; let requested_file = requested_dir.path().join("allowed-write.txt"); let command = format!( "printf {:?} > {:?} && cat {:?}", "folder-grant-ok", requested_file, requested_file ); let requested_permissions = requested_directory_write_permissions(requested_dir.path()); let normalized_requested_permissions = normalized_directory_write_permissions(requested_dir.path())?; let responses = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-request-permissions-1"), request_permissions_tool_event( "permissions-call", "Allow writing outside the workspace", &requested_permissions, )?, ev_completed("resp-request-permissions-1"), ]), sse(vec![ ev_response_created("resp-request-permissions-2"), exec_command_event("exec-call", &command)?, ev_completed("resp-request-permissions-2"), ]), sse(vec![ ev_response_created("resp-request-permissions-3"), ev_assistant_message("msg-request-permissions-1", "done"), ev_completed("resp-request-permissions-3"), ]), ], ) .await; submit_turn( &test, "write outside the workspace", approval_policy, permission_profile, Some(ApprovalsReviewer::User), ) .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, scope: PermissionGrantScope::Turn, strict_auto_review: false, }, }) .await?; let completion_event = wait_for_event(&test.codex, |event| { matches!( event, EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) ) }) .await; if let EventMsg::ExecApprovalRequest(approval) = completion_event { test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, }) .await?; wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) }) .await; } let exec_output = responses .function_call_output_text("exec-call") .map(|output| json!({ "output": output })) .expect("expected exec-call output"); let (exit_code, stdout) = parse_result(&exec_output); assert!(exit_code.is_none() || exit_code == Some(0)); assert!(stdout.contains("folder-grant-ok")); assert!( requested_file.exists(), "touch command should create the file" ); assert_eq!(fs::read_to_string(&requested_file)?, "folder-grant-ok"); Ok(())}#[tokio::test(flavor = "current_thread")]#[cfg(target_os = "macos")]async fn approved_folder_write_request_permissions_unblocks_later_apply_patch() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); apply_patch_after_request_permissions(/*strict_auto_review*/ false).await?; apply_patch_after_request_permissions(/*strict_auto_review*/ true).await?; Ok(())}async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let permission_profile = workspace_write_excluding_tmp(); let permission_profile_for_config = permission_profile.clone(); 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 requested_dir = tempfile::tempdir()?; let requested_file_name = if strict_auto_review { "strict-allowed-patch.txt" } else { "allowed-patch.txt" }; let patch_content = if strict_auto_review { "patched-after-strict-review" } else { "patched-via-request-permissions" }; let requested_file = requested_dir .path() .canonicalize()? .join(requested_file_name); let requested_permissions = requested_directory_write_permissions(requested_dir.path()); let normalized_requested_permissions = normalized_directory_write_permissions(requested_dir.path())?; let patch = build_add_file_patch(&requested_file, patch_content); let response_prefix = if strict_auto_review { "resp-strict-request-permissions-patch" } else { "resp-request-permissions-patch" }; let mut sse_sequence = vec![ sse(vec![ ev_response_created(&format!("{response_prefix}-1")), request_permissions_tool_event( "permissions-call", "Allow patching outside the workspace", &requested_permissions, )?, ev_completed(&format!("{response_prefix}-1")), ]), sse(vec![ ev_response_created(&format!("{response_prefix}-2")), ev_apply_patch_custom_tool_call("apply-patch-call", &patch), ev_completed(&format!("{response_prefix}-2")), ]), ]; if strict_auto_review { sse_sequence.push(sse(vec![ ev_response_created(&format!("{response_prefix}-guardian")), ev_assistant_message( "msg-strict-request-permissions-patch-guardian", &serde_json::json!({ "risk_level": "low", "user_authorization": "high", "outcome": "allow", "rationale": "The patch stays within the strict turn grant.", }) .to_string(), ), ev_completed(&format!("{response_prefix}-guardian")), ])); } sse_sequence.push(sse(vec![ ev_response_created(&format!("{response_prefix}-3")), ev_assistant_message("msg-request-permissions-patch-1", "done"), ev_completed(&format!("{response_prefix}-3")), ])); let responses = mount_sse_sequence(&server, sse_sequence).await; submit_turn( &test, "patch outside the workspace", approval_policy, permission_profile, Some(ApprovalsReviewer::User), ) .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, scope: PermissionGrantScope::Turn, strict_auto_review, }, }) .await?; if strict_auto_review { wait_for_completion(&test).await; let guardian_request = responses .requests() .into_iter() .find(|request| request.body_contains_text(requested_file_name)) .expect("expected guardian request for strict apply_patch"); assert!(guardian_request.body_contains_text(requested_file_name)); assert!(guardian_request.body_contains_text(patch_content)); } else { let event = wait_for_event(&test.codex, |event| { matches!( event, EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_) ) }) .await; match event { EventMsg::TurnComplete(_) => {} EventMsg::ApplyPatchApprovalRequest(approval) => { panic!( "unexpected apply_patch approval request after granted permissions: {approval:?}", ) } other => panic!("unexpected event: {other:?}"), } } let patch_output = responses .requests() .into_iter() .find_map(|request| { request .input() .into_iter() .find(|item| { item.get("type").and_then(Value::as_str) == Some("custom_tool_call_output") && item.get("call_id").and_then(Value::as_str) == Some("apply-patch-call") }) .and_then(|item| { item.get("output") .and_then(Value::as_str) .map(str::to_string) }) }) .map(|output| json!({ "output": output })) .expect("expected apply-patch-call output"); let (exit_code, stdout) = parse_result(&patch_output); assert!(exit_code.is_none() || exit_code == Some(0)); assert!( stdout.contains("Success."), "unexpected patch output: {stdout}" ); assert_eq!( fs::read_to_string(&requested_file)?, format!("{patch_content}\n") ); Ok(())}