diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index 15df4c32a5..bd031127b7 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -1,19 +1,13 @@ use anyhow::Context; use anyhow::Result; -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_exec_server::RemoveOptions; use codex_features::Feature; use codex_protocol::models::PermissionProfile; -use codex_protocol::permissions::FileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_path_uri::PathUri; use core_test_support::TestTargetOs; use core_test_support::responses::ResponseMock; +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; @@ -21,36 +15,22 @@ 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_wine_exec; +use core_test_support::skip_if_target_windows; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::test_target_os; -use serde_json::Value; use serde_json::json; use wiremock::MockServer; -const IMAGE_CALL_ID: &str = "workspace-root-image"; +const PATCH_CALL_ID: &str = "workspace-root-patch"; const COMMAND_CALL_ID: &str = "workspace-root-command"; -const PNG_BASE64: &str = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; -fn workspace_roots_read_profile() -> PermissionProfile { - PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Minimal, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, - access: FileSystemAccessMode::Read, - }, - ]), +fn workspace_roots_profile() -> PermissionProfile { + PermissionProfile::workspace_write_with( + &[], NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, ) } @@ -75,35 +55,36 @@ fn outside_workspace_path(test: &TestCodex, file_name: &str) -> Result .map_err(Into::into) } -fn command_arguments(path: &str) -> Result { +fn command_arguments(path: &str, contents: &str) -> Result { let (shell, command) = match test_target_os() { - TestTargetOs::Linux | TestTargetOs::MacOs => ("bash", format!("cat '{path}'")), - TestTargetOs::Windows => ("powershell", format!("Get-Content -Raw '{path}'")), + TestTargetOs::Linux | TestTargetOs::MacOs => { + ("bash", format!("printf %s '{contents}' > '{path}'")) + } + TestTargetOs::Windows => ( + "powershell", + format!("Set-Content -NoNewline -Path '{path}' -Value '{contents}'"), + ), }; Ok(serde_json::to_string(&json!({ "cmd": command, "shell": shell, "login": false, - "yield_time_ms": 10_000, }))?) } -async fn mount_file_and_command_calls( +async fn mount_patch_and_command_calls( server: &MockServer, - image_path: &str, + patch: &str, command_path: &str, + command_contents: &str, ) -> Result { - let command_arguments = command_arguments(command_path)?; + let command_arguments = command_arguments(command_path, command_contents)?; Ok(mount_sse_sequence( server, vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call( - IMAGE_CALL_ID, - "view_image", - &json!({ "path": image_path }).to_string(), - ), + ev_apply_patch_custom_tool_call(PATCH_CALL_ID, patch), ev_completed("resp-1"), ]), sse(vec![ @@ -122,10 +103,16 @@ async fn mount_file_and_command_calls( } async fn submit_workspace_turn(test: &TestCodex, prompt: &str) -> Result<()> { - test.submit_turn_with_permission_profile(prompt, workspace_roots_read_profile()) + test.submit_turn_with_permission_profile(prompt, workspace_roots_profile()) .await } +async fn read_file(test: &TestCodex, path: &PathUri) -> Result { + Ok(String::from_utf8( + test.fs().read_file(path, /*sandbox*/ None).await?, + )?) +} + async fn remove_files(test: &TestCodex, paths: &[&PathUri]) -> Result<()> { for path in paths { test.fs() @@ -143,125 +130,113 @@ async fn remove_files(test: &TestCodex, paths: &[&PathUri]) -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn workspace_roots_allow_file_read_and_command_run() -> Result<()> { +async fn workspace_roots_allow_file_and_command_writes() -> Result<()> { + const PATCH_CONTENTS: &str = "workspace root patch access"; const COMMAND_CONTENTS: &str = "workspace root command access"; - skip_if_wine_exec!( + skip_if_target_windows!( Ok(()), - "remote Windows sandboxed process launches are not supported" + "sandboxed process launch is not supported by the exec-server Windows backend" ); let server = start_mock_server().await; let test = workspace_roots_test(&server).await?; let cwd = PathUri::from_abs_path(&test.config.cwd); - let image_path = cwd.join("workspace-root.png")?; - let text_path = cwd.join("workspace-root.txt")?; - test.fs() - .write_file( - &image_path, - BASE64_STANDARD.decode(PNG_BASE64)?, - /*sandbox*/ None, - ) - .await?; - test.fs() - .write_file( - &text_path, - COMMAND_CONTENTS.as_bytes().to_vec(), - /*sandbox*/ None, - ) - .await?; + let patch_path = cwd.join("workspace-root-patch.txt")?; + let command_path = cwd.join("workspace-root-command.txt")?; + let patch = format!( + "*** Begin Patch\n*** Add File: workspace-root-patch.txt\n+{PATCH_CONTENTS}\n*** End Patch\n" + ); - let response_mock = - mount_file_and_command_calls(&server, "workspace-root.png", "workspace-root.txt").await?; - submit_workspace_turn(&test, "read files inside the workspace roots").await?; + let response_mock = mount_patch_and_command_calls( + &server, + &patch, + "workspace-root-command.txt", + COMMAND_CONTENTS, + ) + .await?; + submit_workspace_turn(&test, "write files inside the workspace roots").await?; let request = response_mock .last_request() .context("model should receive both workspace-root tool results")?; - let image_output = request.function_call_output(IMAGE_CALL_ID); - let image_url = image_output - .get("output") - .and_then(Value::as_array) - .and_then(|items| items.first()) - .and_then(|item| item.get("image_url")) - .and_then(Value::as_str) - .context("filesystem read should return an image")?; - assert!(image_url.starts_with("data:image/png;base64,")); + let (_, patch_success) = request + .custom_tool_call_output_content_and_success(PATCH_CALL_ID) + .context("patch result should be present")?; + assert_ne!(patch_success, Some(false)); - let (command_output, success) = request + let (_, command_success) = request .function_call_output_content_and_success(COMMAND_CALL_ID) .context("command result should be present")?; - assert_ne!(success, Some(false)); - assert!( - command_output - .as_deref() - .is_some_and(|output| output.contains(COMMAND_CONTENTS)), - "command should read the workspace-root file, got {command_output:?}" + assert_ne!(command_success, Some(false)); + assert_eq!( + read_file(&test, &patch_path).await?, + format!("{PATCH_CONTENTS}\n") ); + assert_eq!(read_file(&test, &command_path).await?, COMMAND_CONTENTS); - remove_files(&test, &[&image_path, &text_path]).await + remove_files(&test, &[&patch_path, &command_path]).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn workspace_roots_deny_file_and_command_reads_outside_roots() -> Result<()> { - const OUTSIDE_CONTENTS: &str = "outside workspace root"; +async fn workspace_roots_deny_file_and_command_writes_outside_roots() -> Result<()> { + const PATCH_CONTENTS: &str = "outside workspace root patch"; + const COMMAND_CONTENTS: &str = "outside workspace root command"; - skip_if_wine_exec!( + skip_if_target_windows!( Ok(()), - "remote Windows sandboxed process launches are not supported" + "sandboxed process launch is not supported by the exec-server Windows backend" ); let server = start_mock_server().await; let test = workspace_roots_test(&server).await?; - let image_path = outside_workspace_path(&test, "outside.png")?; - let text_path = outside_workspace_path(&test, "outside.txt")?; - test.fs() - .write_file( - &image_path, - BASE64_STANDARD.decode(PNG_BASE64)?, - /*sandbox*/ None, - ) - .await?; - test.fs() - .write_file( - &text_path, - OUTSIDE_CONTENTS.as_bytes().to_vec(), - /*sandbox*/ None, - ) - .await?; - let image_path_display = image_path.inferred_native_path_string(); - let text_path_display = text_path.inferred_native_path_string(); + let patch_path = outside_workspace_path(&test, "outside-patch.txt")?; + let command_path = outside_workspace_path(&test, "outside-command.txt")?; + let patch_path_display = patch_path.inferred_native_path_string(); + let command_path_display = command_path.inferred_native_path_string(); + let patch = format!( + "*** Begin Patch\n*** Add File: {patch_path_display}\n+{PATCH_CONTENTS}\n*** End Patch\n" + ); let response_mock = - mount_file_and_command_calls(&server, &image_path_display, &text_path_display).await?; - submit_workspace_turn(&test, "try to read files outside the workspace roots").await?; + mount_patch_and_command_calls(&server, &patch, &command_path_display, COMMAND_CONTENTS) + .await?; + submit_workspace_turn(&test, "try to write files outside the workspace roots").await?; let request = response_mock .last_request() .context("model should receive both denied tool results")?; - let (file_output, _) = request - .function_call_output_content_and_success(IMAGE_CALL_ID) - .context("denied file-read result should be present")?; - assert!(file_output.is_some_and(|output| { - output.starts_with(&format!( - "unable to locate image at `{image_path_display}`:" - )) || output.starts_with(&format!("unable to read image at `{image_path_display}`:")) - })); + let (patch_output, patch_success) = request + .custom_tool_call_output_content_and_success(PATCH_CALL_ID) + .context("denied patch result should be present")?; + assert_ne!(patch_success, Some(true)); + assert!( + patch_output + .as_deref() + .is_some_and(|output| output.contains("outside of the project")), + "patch should be denied outside the workspace roots, got {patch_output:?}" + ); let (command_output, _) = request .function_call_output_content_and_success(COMMAND_CALL_ID) .context("denied command result should be present")?; let command_output = command_output.context("denied command output should be present")?; assert!( - command_output.contains(&text_path_display), - "denied command output should identify {text_path_display}, got {command_output:?}" + command_output.contains(&command_path_display), + "denied command output should identify {command_path_display}, got {command_output:?}" ); assert!( - command_output.contains("Permission denied") - || command_output.contains("Operation not permitted") - || command_output.contains("No such file or directory") + test.fs() + .read_file(&patch_path, /*sandbox*/ None) + .await + .is_err() + ); + assert!( + test.fs() + .read_file(&command_path, /*sandbox*/ None) + .await + .is_err() ); - assert!(!command_output.contains(OUTSIDE_CONTENTS)); - remove_files(&test, &[&image_path, &text_path]).await + Ok(()) }