From 91b30d8116780360b013ec85307ca59f0fb408ca Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 29 May 2026 21:17:48 -0300 Subject: [PATCH] feat(core): add model workspace mutation tools --- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/tools/handlers/mod.rs | 3 + .../src/tools/handlers/workspace_mutation.rs | 346 +++++++++++ .../tools/handlers/workspace_mutation_spec.rs | 38 ++ codex-rs/core/src/tools/spec_plan.rs | 6 + codex-rs/core/src/tools/spec_plan_tests.rs | 8 + codex-rs/core/tests/suite/tools.rs | 551 +++++++++++++++++- 8 files changed, 952 insertions(+), 2 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/workspace_mutation.rs create mode 100644 codex-rs/core/src/tools/handlers/workspace_mutation_spec.rs diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index aa5784d4fe..2710282ad0 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -110,6 +110,7 @@ pub(crate) mod web_search; pub(crate) mod windows_sandbox_read_grants; pub use thread_manager::ForkSnapshot; pub use thread_manager::NewThread; +pub use thread_manager::RuntimeWorkspaceReplayOverrides; pub use thread_manager::StartThreadOptions; pub use thread_manager::ThreadManager; pub use thread_manager::ThreadShutdownReport; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index bf637d3372..0db52a406b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -213,6 +213,7 @@ use self::config_lock::validate_config_lock_if_configured; #[cfg(test)] use self::handlers::submission_dispatch_span; use self::handlers::submission_loop; +pub(crate) use self::handlers::thread_settings_applied_event; pub(crate) use self::input_queue::TurnInput; pub(crate) use self::input_queue::TurnInputQueue; use self::review::spawn_review_thread; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index b2cbe62907..1a2e935b2d 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -31,6 +31,8 @@ pub(crate) mod tool_search_spec; pub(crate) mod unified_exec; mod view_image; pub(crate) mod view_image_spec; +mod workspace_mutation; +pub(crate) mod workspace_mutation_spec; use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_sandboxing::policy_transforms::merge_permission_profiles; @@ -73,6 +75,7 @@ pub use unified_exec::ExecCommandHandler; pub(crate) use unified_exec::ExecCommandHandlerOptions; pub use unified_exec::WriteStdinHandler; pub use view_image::ViewImageHandler; +pub(crate) use workspace_mutation::WorkspaceMutationHandler; pub(crate) fn parse_arguments(arguments: &str) -> Result where diff --git a/codex-rs/core/src/tools/handlers/workspace_mutation.rs b/codex-rs/core/src/tools/handlers/workspace_mutation.rs new file mode 100644 index 0000000000..7f735ea142 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/workspace_mutation.rs @@ -0,0 +1,346 @@ +use crate::function_tool::FunctionCallError; +use crate::session::session::SessionSettingsUpdate; +use crate::session::thread_settings_applied_event; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::context::boxed_tool_output; +use crate::tools::handlers::parse_arguments; +use crate::tools::handlers::workspace_mutation_spec::create_add_workspace_root_tool; +use crate::tools::handlers::workspace_mutation_spec::create_set_working_directory_tool; +use crate::tools::registry::CoreToolRuntime; +use crate::tools::registry::ToolExecutor; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; +use codex_protocol::request_permissions::RequestPermissionsArgs; +use codex_protocol::request_permissions::WorkspaceMutationApprovalRequest; +use codex_protocol::request_permissions::WorkspaceMutationOperation; +use codex_tools::ToolName; +use codex_tools::ToolSpec; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; +use std::io; + +#[derive(Clone, Copy)] +enum WorkspaceMutation { + SetWorkingDirectory, + AddWorkspaceRoot, +} + +pub(crate) struct WorkspaceMutationHandler { + mutation: WorkspaceMutation, +} + +impl WorkspaceMutationHandler { + pub(crate) fn set_working_directory() -> Self { + Self { + mutation: WorkspaceMutation::SetWorkingDirectory, + } + } + + pub(crate) fn add_workspace_root() -> Self { + Self { + mutation: WorkspaceMutation::AddWorkspaceRoot, + } + } +} + +#[derive(Deserialize)] +struct WorkspaceMutationArgs { + path: String, +} + +#[derive(Serialize)] +struct WorkspaceMutationResult { + changed: bool, + cwd: AbsolutePathBuf, + workspace_roots: Vec, +} + +#[derive(Serialize)] +struct WorkspaceMutationError { + code: &'static str, + message: String, + cwd: AbsolutePathBuf, + workspace_roots: Vec, +} + +#[async_trait::async_trait] +impl ToolExecutor for WorkspaceMutationHandler { + fn tool_name(&self) -> ToolName { + ToolName::plain(match self.mutation { + WorkspaceMutation::SetWorkingDirectory => "set_working_directory", + WorkspaceMutation::AddWorkspaceRoot => "add_workspace_root", + }) + } + + fn spec(&self) -> ToolSpec { + match self.mutation { + WorkspaceMutation::SetWorkingDirectory => create_set_working_directory_tool(), + WorkspaceMutation::AddWorkspaceRoot => create_add_workspace_root_tool(), + } + } + + async fn handle( + &self, + invocation: ToolInvocation, + ) -> Result, FunctionCallError> { + let ToolInvocation { + session, + turn, + cancellation_token, + call_id, + payload, + .. + } = invocation; + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "workspace mutation handler received unsupported payload".to_string(), + )); + } + }; + let args: WorkspaceMutationArgs = parse_arguments(&arguments)?; + let current = turn.runtime_workspace.snapshot().await; + let requested = current.cwd.join(args.path); + let Some(environment) = turn.environments.primary() else { + return Err(FunctionCallError::RespondToModel( + "workspace mutation is unavailable without an execution environment".to_string(), + )); + }; + let fs = environment.environment.get_filesystem(); + let canonical = match fs.canonicalize(&requested, /*sandbox*/ None).await { + Ok(path) => path, + Err(err) => { + return workspace_error( + io_error_code(&err), + err.to_string(), + current.cwd, + current.workspace_roots, + ); + } + }; + let metadata = match fs.get_metadata(&canonical, /*sandbox*/ None).await { + Ok(metadata) => metadata, + Err(err) => { + return workspace_error( + io_error_code(&err), + err.to_string(), + current.cwd, + current.workspace_roots, + ); + } + }; + if !metadata.is_directory { + return workspace_error( + "not_a_directory", + format!( + "workspace mutation target is not a directory: {}", + canonical.as_path().display() + ), + current.cwd, + current.workspace_roots, + ); + } + + let mut workspace_roots = current.workspace_roots.clone(); + if !workspace_roots + .iter() + .any(|root| canonical.as_path().starts_with(root.as_path())) + { + workspace_roots.push(canonical.clone()); + } + let cwd = match self.mutation { + WorkspaceMutation::SetWorkingDirectory => canonical.clone(), + WorkspaceMutation::AddWorkspaceRoot => current.cwd.clone(), + }; + let changed = cwd != current.cwd || workspace_roots != current.workspace_roots; + if !changed { + return workspace_success(/*changed*/ false, cwd, workspace_roots); + } + + let preview = session + .preview_settings(&SessionSettingsUpdate { + cwd: matches!(self.mutation, WorkspaceMutation::SetWorkingDirectory) + .then(|| cwd.to_path_buf()), + workspace_roots: Some(workspace_roots.clone()), + ..Default::default() + }) + .await + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; + let current_policy = current.permission_profile.file_system_sandbox_policy(); + let preview_policy = preview.permission_profile.file_system_sandbox_policy(); + if matches!(self.mutation, WorkspaceMutation::SetWorkingDirectory) + && !preview_policy.can_read_path_with_cwd(canonical.as_path(), cwd.as_path()) + { + return workspace_error( + "permission_denied", + format!( + "working directory is not readable under the active permission profile: {}", + canonical.as_path().display() + ), + current.cwd, + current.workspace_roots, + ); + } + let requested_permissions = if preview_policy + .can_write_path_with_cwd(canonical.as_path(), cwd.as_path()) + && !current_policy.can_write_path_with_cwd(canonical.as_path(), current.cwd.as_path()) + { + Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![canonical.clone()]), + )) + } else if preview_policy.can_read_path_with_cwd(canonical.as_path(), cwd.as_path()) + && !current_policy.can_read_path_with_cwd(canonical.as_path(), current.cwd.as_path()) + { + Some(FileSystemPermissions::from_read_write_roots( + Some(vec![canonical.clone()]), + /*write*/ None, + )) + } else { + None + }; + if let Some(file_system) = requested_permissions { + let response = session + .request_workspace_permissions_for_cwd( + &turn, + call_id, + RequestPermissionsArgs { + reason: Some(match self.mutation { + WorkspaceMutation::SetWorkingDirectory => format!( + "switch this session's working directory to `{}`", + canonical.as_path().display() + ), + WorkspaceMutation::AddWorkspaceRoot => format!( + "add `{}` to this session's workspace", + canonical.as_path().display() + ), + }), + permissions: RequestPermissionProfile { + file_system: Some(file_system), + network: None, + }, + }, + current.cwd.clone(), + WorkspaceMutationApprovalRequest { + operation: match self.mutation { + WorkspaceMutation::SetWorkingDirectory => { + WorkspaceMutationOperation::SetWorkingDirectory + } + WorkspaceMutation::AddWorkspaceRoot => { + WorkspaceMutationOperation::AddWorkspaceRoot + } + }, + target: canonical.clone(), + resulting_workspace_roots: workspace_roots.clone(), + }, + cancellation_token, + ) + .await; + let Some(response) = response else { + return workspace_error( + "approval_denied", + "workspace mutation approval was cancelled".to_string(), + current.cwd, + current.workspace_roots, + ); + }; + if response.permissions.is_empty() + || !matches!(response.scope, PermissionGrantScope::Session) + { + return workspace_error( + "approval_denied", + "workspace mutation requires session-scoped approval".to_string(), + current.cwd, + current.workspace_roots, + ); + } + } + + session + .update_runtime_workspace( + turn.as_ref(), + matches!(self.mutation, WorkspaceMutation::SetWorkingDirectory) + .then_some(cwd.clone()), + workspace_roots.clone(), + ) + .await + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; + session + .send_event( + turn.as_ref(), + thread_settings_applied_event(session.as_ref()).await, + ) + .await; + workspace_success(/*changed*/ true, cwd, workspace_roots) + } +} + +impl CoreToolRuntime for WorkspaceMutationHandler { + fn execution_barrier(&self) -> bool { + true + } + + fn cancel_suffix_on_failure(&self) -> bool { + true + } +} + +fn workspace_success( + changed: bool, + cwd: AbsolutePathBuf, + workspace_roots: Vec, +) -> Result, FunctionCallError> { + workspace_output( + WorkspaceMutationResult { + changed, + cwd, + workspace_roots, + }, + /*success*/ true, + ) +} + +fn workspace_error( + code: &'static str, + message: String, + cwd: AbsolutePathBuf, + workspace_roots: Vec, +) -> Result, FunctionCallError> { + workspace_output( + WorkspaceMutationError { + code, + message, + cwd, + workspace_roots, + }, + /*success*/ false, + ) +} + +fn workspace_output( + output: impl Serialize, + success: bool, +) -> Result, FunctionCallError> { + let content = serde_json::to_string(&output).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize workspace mutation result: {err}" + )) + })?; + Ok(boxed_tool_output(FunctionToolOutput::from_text( + content, + Some(success), + ))) +} + +fn io_error_code(err: &io::Error) -> &'static str { + match err.kind() { + io::ErrorKind::NotFound => "path_not_found", + io::ErrorKind::PermissionDenied => "permission_denied", + _ => "resolution_failed", + } +} diff --git a/codex-rs/core/src/tools/handlers/workspace_mutation_spec.rs b/codex-rs/core/src/tools/handlers/workspace_mutation_spec.rs new file mode 100644 index 0000000000..8449aa4ffb --- /dev/null +++ b/codex-rs/core/src/tools/handlers/workspace_mutation_spec.rs @@ -0,0 +1,38 @@ +use codex_tools::JsonSchema; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolSpec; +use std::collections::BTreeMap; + +pub(crate) fn create_set_working_directory_tool() -> ToolSpec { + create_workspace_mutation_tool( + "set_working_directory", + "Changes the active working directory and adds it as a workspace root when needed.", + ) +} + +pub(crate) fn create_add_workspace_root_tool() -> ToolSpec { + create_workspace_mutation_tool( + "add_workspace_root", + "Adds a workspace root without changing the active working directory.", + ) +} + +fn create_workspace_mutation_tool(name: &str, summary: &str) -> ToolSpec { + ToolSpec::Function(ResponsesApiTool { + name: name.to_string(), + description: format!( + "{summary}\n\nRelative paths resolve from the active working directory. Later tool calls in the same batch start after this mutation succeeds and are cancelled if it fails." + ), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + BTreeMap::from([( + "path".to_string(), + JsonSchema::string(Some("Existing directory path.".to_string())), + )]), + Some(vec!["path".to_string()]), + /*additional_properties*/ Some(false.into()), + ), + output_schema: None, + }) +} diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index facef69cbb..754ba39ab7 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -24,6 +24,7 @@ use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::ToolSearchHandler; use crate::tools::handlers::UpdateGoalHandler; use crate::tools::handlers::ViewImageHandler; +use crate::tools::handlers::WorkspaceMutationHandler; use crate::tools::handlers::WriteStdinHandler; use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler; use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler; @@ -610,6 +611,11 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut planned_tools.add(RequestPermissionsHandler); } + if matches!(environment_mode, ToolEnvironmentMode::Single) { + planned_tools.add(WorkspaceMutationHandler::set_working_directory()); + planned_tools.add(WorkspaceMutationHandler::add_workspace_root()); + } + if tool_suggest_enabled(turn_context) && let Some(discoverable_tools) = context.discoverable_tools.filter(|tools| !tools.is_empty()) diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index e2b14b5b14..c810c1b969 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -421,14 +421,21 @@ async fn environment_count_controls_environment_backed_tools() { "exec_command", "apply_patch", "view_image", + "set_working_directory", + "add_workspace_root", ]); no_environment.assert_registered_lacks(&[ "shell_command", "exec_command", "apply_patch", "view_image", + "set_working_directory", + "add_workspace_root", ]); + let single_environment = probe(|_| {}).await; + single_environment.assert_visible_contains(&["set_working_directory", "add_workspace_root"]); + let multiple_environments = probe(|turn| { duplicate_primary_environment(turn); set_feature(turn, Feature::ShellTool, /*enabled*/ true); @@ -437,6 +444,7 @@ async fn environment_count_controls_environment_backed_tools() { }) .await; multiple_environments.assert_visible_contains(&["exec_command", "apply_patch", "view_image"]); + multiple_environments.assert_visible_lacks(&["set_working_directory", "add_workspace_root"]); assert!(has_parameter( multiple_environments.visible_spec("exec_command"), "environment_id" diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 7702ab391b..d578a655bf 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -9,6 +9,8 @@ use anyhow::Context; use anyhow::Result; use codex_core::sandboxing::SandboxPermissions; use codex_features::Feature; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -16,7 +18,16 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; +use codex_protocol::request_permissions::RequestPermissionsEvent; +use codex_protocol::request_permissions::RequestPermissionsResponse; +use codex_protocol::request_permissions::WorkspaceMutationOperation; +use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -29,7 +40,10 @@ 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::test_codex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event; use regex_lite::Regex; use serde_json::Value; use serde_json::json; @@ -51,6 +65,84 @@ fn tool_names(body: &Value) -> Vec { .unwrap_or_default() } +fn workspace_write_excluding_tmp() -> PermissionProfile { + PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ) +} + +async fn submit_workspace_mutation_turn( + test: &TestCodex, + prompt: &str, + approval_policy: AskForApproval, + permission_profile: PermissionProfile, +) -> 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(), + }], + environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.config.cwd.to_path_buf()), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::User), + 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 expect_workspace_mutation_request( + test: &TestCodex, + expected_call_id: &str, +) -> RequestPermissionsEvent { + 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 + } + EventMsg::TurnComplete(_) => panic!("expected request_permissions before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + +async fn wait_for_completion(test: &TestCodex) { + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn empty_turn_environments_omits_environment_backed_tools() -> Result<()> { skip_if_no_network!(Ok(())); @@ -82,7 +174,14 @@ async fn empty_turn_environments_omits_environment_backed_tools() -> Result<()> tools.contains(&"update_plan".to_string()), "non-environment tool should remain available; got {tools:?}" ); - for environment_tool in ["exec_command", "write_stdin", "apply_patch", "view_image"] { + for environment_tool in [ + "exec_command", + "write_stdin", + "apply_patch", + "view_image", + "set_working_directory", + "add_workspace_root", + ] { assert!( !tools.contains(&environment_tool.to_string()), "{environment_tool} should be omitted for explicit empty turn environments; got {tools:?}" @@ -120,7 +219,7 @@ async fn turn_environment_selection_keeps_environment_backed_tools() -> Result<( Some(vec![TurnEnvironmentSelection { environment_id: "local".to_string(), cwd: test.config.cwd.clone(), - workspace_roots: Vec::new(), + workspace_roots: test.config.workspace_roots.clone(), }]), ) .await?; @@ -130,10 +229,458 @@ async fn turn_environment_selection_keeps_environment_backed_tools() -> Result<( tools.contains(&"exec_command".to_string()), "environment tool should remain available with selected local environment; got {tools:?}" ); + assert!(tools.contains(&"set_working_directory".to_string())); + assert!(tools.contains(&"add_workspace_root".to_string())); Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_mutation_updates_same_batch_shell_cwd() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_model("test-gpt-5-codex"); + let test = builder.build(&server).await?; + let next_cwd = test.config.cwd.join("workspace-mutation-next"); + fs::create_dir_all(next_cwd.as_path())?; + let mutation_call_id = "set-cwd"; + let shell_call_id = "pwd"; + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + mutation_call_id, + "set_working_directory", + &serde_json::to_string(&json!({ "path": next_cwd }))?, + ), + ev_function_call( + shell_call_id, + "shell_command", + &serde_json::to_string(&json!({ + "command": "pwd", + "login": false, + "timeout_ms": 1_000, + }))?, + ), + ev_completed("resp-1"), + ]), + ) + .await; + let second_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("change directories and print the working directory") + .await?; + + let request = second_mock.single_request(); + let mutation_output = request + .function_call_output_text(mutation_call_id) + .expect("mutation output"); + assert_eq!( + serde_json::from_str::(&mutation_output)?, + json!({ + "changed": true, + "cwd": next_cwd, + "workspace_roots": test.config.workspace_roots, + }) + ); + let shell_output = request + .function_call_output_text(shell_call_id) + .expect("shell output"); + assert!(shell_output.contains(next_cwd.as_path().to_string_lossy().as_ref())); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_mutations_run_in_model_provided_order() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_model("test-gpt-5-codex"); + let test = builder.build(&server).await?; + let first_cwd = test.config.cwd.join("workspace-mutation-first"); + let second_cwd = first_cwd.join("nested"); + fs::create_dir_all(second_cwd.as_path())?; + let first_mutation_call_id = "set-first-cwd"; + let second_mutation_call_id = "set-second-cwd"; + let shell_call_id = "pwd"; + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + first_mutation_call_id, + "set_working_directory", + &serde_json::to_string(&json!({ "path": "workspace-mutation-first" }))?, + ), + ev_function_call( + second_mutation_call_id, + "set_working_directory", + &serde_json::to_string(&json!({ "path": "nested" }))?, + ), + ev_function_call( + shell_call_id, + "shell_command", + &serde_json::to_string(&json!({ + "command": "pwd", + "login": false, + "timeout_ms": 1_000, + }))?, + ), + ev_completed("resp-1"), + ]), + ) + .await; + let second_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("change directories twice and print the working directory") + .await?; + + let request = second_mock.single_request(); + let first_mutation_output = request + .function_call_output_text(first_mutation_call_id) + .expect("first mutation output"); + assert_eq!( + serde_json::from_str::(&first_mutation_output)?, + json!({ + "changed": true, + "cwd": first_cwd, + "workspace_roots": test.config.workspace_roots, + }) + ); + let second_mutation_output = request + .function_call_output_text(second_mutation_call_id) + .expect("second mutation output"); + assert_eq!( + serde_json::from_str::(&second_mutation_output)?, + json!({ + "changed": true, + "cwd": second_cwd, + "workspace_roots": test.config.workspace_roots, + }) + ); + let shell_output = request + .function_call_output_text(shell_call_id) + .expect("shell output"); + assert!(shell_output.contains(second_cwd.as_path().to_string_lossy().as_ref())); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn add_workspace_root_under_existing_root_is_noop() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_model("test-gpt-5-codex"); + let test = builder.build(&server).await?; + let covered_child = test.config.cwd.join("covered-child"); + fs::create_dir_all(covered_child.as_path())?; + let mutation_call_id = "add-covered-root"; + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + mutation_call_id, + "add_workspace_root", + &serde_json::to_string(&json!({ "path": covered_child }))?, + ), + ev_completed("resp-1"), + ]), + ) + .await; + let second_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + test.submit_turn("add an already covered workspace root") + .await?; + + let output = second_mock + .single_request() + .function_call_output_text(mutation_call_id) + .expect("mutation output"); + assert_eq!( + serde_json::from_str::(&output)?, + json!({ + "changed": false, + "cwd": test.config.cwd, + "workspace_roots": test.config.workspace_roots, + }) + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_mutation_requires_session_scoped_approval_and_cancels_suffix() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_model("test-gpt-5-codex"); + let test = builder.build(&server).await?; + let external_root = tempfile::tempdir()?; + let external_root = AbsolutePathBuf::try_from(external_root.path().canonicalize()?)?; + let mutation_call_id = "add-external-root"; + let suffix_call_id = "suffix-pwd"; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + mutation_call_id, + "add_workspace_root", + &serde_json::to_string(&json!({ "path": external_root }))?, + ), + ev_function_call( + suffix_call_id, + "shell_command", + &serde_json::to_string(&json!({ + "command": "pwd", + "login": false, + "timeout_ms": 1_000, + }))?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + submit_workspace_mutation_turn( + &test, + "add an external workspace root and print cwd", + AskForApproval::OnRequest, + workspace_write_excluding_tmp(), + ) + .await?; + let request = expect_workspace_mutation_request(&test, mutation_call_id).await; + let mut expected_workspace_roots = test.config.workspace_roots.clone(); + expected_workspace_roots.push(external_root.clone()); + let expected_permissions = RequestPermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![external_root.clone()]), + )), + ..Default::default() + }; + assert_eq!(request.permissions, expected_permissions); + assert_eq!( + request.workspace_mutation, + Some( + codex_protocol::request_permissions::WorkspaceMutationApprovalRequest { + operation: WorkspaceMutationOperation::AddWorkspaceRoot, + target: external_root.clone(), + resulting_workspace_roots: expected_workspace_roots, + } + ) + ); + test.codex + .submit(Op::RequestPermissionsResponse { + id: mutation_call_id.to_string(), + response: RequestPermissionsResponse { + permissions: expected_permissions, + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }, + }) + .await?; + wait_for_completion(&test).await; + + let mutation_output = responses + .function_call_output_text(mutation_call_id) + .expect("mutation output"); + assert_eq!( + serde_json::from_str::(&mutation_output)?, + json!({ + "code": "approval_denied", + "message": "workspace mutation requires session-scoped approval", + "cwd": test.config.cwd, + "workspace_roots": test.config.workspace_roots, + }) + ); + let suffix_output = responses + .function_call_output_text(suffix_call_id) + .expect("cancelled suffix output"); + assert_eq!( + serde_json::from_str::(&suffix_output)?, + json!({ + "code": "dependency_cancelled", + "message": format!("cancelled because workspace mutation `{mutation_call_id}` failed"), + "failed_mutation_call_id": mutation_call_id, + "failed_mutation_code": "approval_denied", + }) + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn approved_workspace_root_is_available_to_same_batch_shell() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_model("test-gpt-5-codex"); + let test = builder.build(&server).await?; + let external_root = tempfile::tempdir()?; + let external_root = AbsolutePathBuf::try_from(external_root.path().canonicalize()?)?; + let mutation_call_id = "approve-external-root"; + let shell_call_id = "pwd-external-root"; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + mutation_call_id, + "add_workspace_root", + &serde_json::to_string(&json!({ "path": external_root }))?, + ), + ev_function_call( + shell_call_id, + "shell_command", + &serde_json::to_string(&json!({ + "command": "pwd", + "workdir": external_root, + "login": false, + "timeout_ms": 1_000, + }))?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + submit_workspace_mutation_turn( + &test, + "add an external workspace root and use it immediately", + AskForApproval::OnRequest, + workspace_write_excluding_tmp(), + ) + .await?; + let request = expect_workspace_mutation_request(&test, mutation_call_id).await; + test.codex + .submit(Op::RequestPermissionsResponse { + id: mutation_call_id.to_string(), + response: RequestPermissionsResponse { + permissions: request.permissions, + scope: PermissionGrantScope::Session, + strict_auto_review: false, + }, + }) + .await?; + wait_for_completion(&test).await; + + let mut expected_workspace_roots = test.config.workspace_roots.clone(); + expected_workspace_roots.push(external_root.clone()); + let mutation_output = responses + .function_call_output_text(mutation_call_id) + .expect("mutation output"); + assert_eq!( + serde_json::from_str::(&mutation_output)?, + json!({ + "changed": true, + "cwd": test.config.cwd, + "workspace_roots": expected_workspace_roots, + }) + ); + let shell_output = responses + .function_call_output_text(shell_call_id) + .expect("shell output"); + assert!(shell_output.contains(external_root.as_path().to_string_lossy().as_ref())); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn set_working_directory_rejects_unreadable_target() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_model("test-gpt-5-codex"); + let test = builder.build(&server).await?; + let external_root = tempfile::tempdir()?; + let external_root = AbsolutePathBuf::try_from(external_root.path().canonicalize()?)?; + let mutation_call_id = "set-unreadable-cwd"; + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + mutation_call_id, + "set_working_directory", + &serde_json::to_string(&json!({ "path": external_root }))?, + ), + ev_completed("resp-1"), + ]), + ) + .await; + let second_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + let permission_profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(Vec::new()), + NetworkSandboxPolicy::Restricted, + ); + + test.submit_turn_with_approval_and_permission_profile( + "change to an unreadable directory", + AskForApproval::OnRequest, + permission_profile, + ) + .await?; + + let output = second_mock + .single_request() + .function_call_output_text(mutation_call_id) + .expect("mutation output"); + assert_eq!( + serde_json::from_str::(&output)?, + json!({ + "code": "permission_denied", + "message": format!( + "working directory is not readable under the active permission profile: {}", + external_root.as_path().display() + ), + "cwd": test.config.cwd, + "workspace_roots": test.config.workspace_roots, + }) + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> { skip_if_no_network!(Ok(()));