diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index d7302bbbf8ab..2de6e923d97d 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -241,3 +241,66 @@ pub(crate) fn build_settings_update_items( } items } + +pub(crate) fn build_runtime_workspace_update_items( + previous: Option<&TurnContextItem>, + next: &TurnContextItem, + turn_context: &TurnContext, + shell: &Shell, + exec_policy: &Policy, +) -> Vec { + let contextual_user_message = if turn_context.config.include_environment_context { + let next_context = + EnvironmentContext::from_turn_context_item(next, shell.name().to_string()); + match previous { + Some(previous) => { + let previous_context = + EnvironmentContext::from_turn_context_item(previous, shell.name().to_string()); + (!previous_context.equals_except_shell(&next_context)).then(|| { + ContextualUserFragment::into(EnvironmentContext::diff_from_turn_context_item( + previous, + &next_context, + )) + }) + } + None => Some(ContextualUserFragment::into(next_context)), + } + } else { + None + }; + + let permissions_changed = previous.is_none_or(|previous| { + previous.permission_profile() != next.permission_profile() + || previous.approval_policy != next.approval_policy + }); + let developer_update_sections = + if turn_context.config.include_permissions_instructions && permissions_changed { + vec![ + PermissionsInstructions::from_permission_profile( + &next.permission_profile(), + next.approval_policy, + turn_context.config.approvals_reviewer, + exec_policy, + &next.cwd, + turn_context + .features + .enabled(Feature::ExecPermissionApprovals), + turn_context + .features + .enabled(Feature::RequestPermissionsTool), + ) + .render(), + ] + } else { + Vec::new() + }; + + let mut items = Vec::with_capacity(/*capacity*/ 2); + if let Some(developer_message) = build_developer_update_item(developer_update_sections) { + items.push(developer_message); + } + if let Some(contextual_user_message) = contextual_user_message { + items.push(contextual_user_message); + } + items +} diff --git a/codex-rs/core/src/guardian/approval_request.rs b/codex-rs/core/src/guardian/approval_request.rs index fba227834af2..5115373a182a 100644 --- a/codex-rs/core/src/guardian/approval_request.rs +++ b/codex-rs/core/src/guardian/approval_request.rs @@ -5,6 +5,7 @@ use codex_protocol::approvals::GuardianAssessmentAction; use codex_protocol::approvals::GuardianCommandSource; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Serialize; @@ -73,6 +74,7 @@ pub(crate) enum GuardianApprovalRequest { turn_id: String, reason: Option, permissions: RequestPermissionProfile, + requested_scope: PermissionGrantScope, }, } @@ -167,6 +169,7 @@ struct RequestPermissionsApprovalAction<'a> { #[serde(skip_serializing_if = "Option::is_none")] reason: Option<&'a String>, permissions: &'a RequestPermissionProfile, + requested_scope: PermissionGrantScope, } fn serialize_guardian_action(value: impl Serialize) -> serde_json::Result { @@ -363,11 +366,13 @@ pub(crate) fn guardian_approval_request_to_json( turn_id, reason, permissions, + requested_scope, } => serialize_guardian_action(RequestPermissionsApprovalAction { tool: "request_permissions", turn_id, reason: reason.as_ref(), permissions, + requested_scope: *requested_scope, }), } } diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 92223083d8d7..1ee910ca9ff3 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -642,26 +642,36 @@ pub(crate) fn spawn_approval_request_review( approval_request_source: GuardianApprovalRequestSource, cancel_token: CancellationToken, ) -> oneshot::Receiver { + // Keep this above the default macOS pthread stack because permission-review + // sessions can recurse through thread materialization and state loading. + const GUARDIAN_REVIEW_THREAD_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024; + let (tx, rx) = oneshot::channel(); - std::thread::spawn(move || { - let Ok(runtime) = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - else { - let _ = tx.send(ReviewDecision::Denied); - return; - }; - let decision = runtime.block_on(review_approval_request_with_cancel( - &session, - &turn, - review_id, - request, - retry_reason, - approval_request_source, - cancel_token, - )); - let _ = tx.send(decision); - }); + // Guardian reviews materialize a nested Codex session and can overflow the + // default macOS pthread stack when reviewing permission requests. + // Dropping the sender if spawning fails closes the channel, which callers treat as denial. + let _ = std::thread::Builder::new() + .name("guardian-approval-request-review".to_string()) + .stack_size(GUARDIAN_REVIEW_THREAD_STACK_SIZE_BYTES) + .spawn(move || { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + let _ = tx.send(ReviewDecision::Denied); + return; + }; + let decision = runtime.block_on(review_approval_request_with_cancel( + &session, + &turn, + review_id, + request, + retry_reason, + approval_request_source, + cancel_token, + )); + let _ = tx.send(decision); + }); rx } diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index d4525cf45d88..b43c53865cfc 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -155,7 +155,7 @@ async fn thread_settings_update( } } -async fn thread_settings_applied_event(sess: &Session) -> EventMsg { +pub(crate) async fn thread_settings_applied_event(sess: &Session) -> EventMsg { let snapshot = { let state = sess.state.lock().await; state.session_configuration.thread_config_snapshot() diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8e67020883f7..a69c6854b616 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1488,6 +1488,62 @@ impl Session { Ok(()) } + pub(crate) async fn runtime_workspace_snapshot(&self) -> session::RuntimeWorkspaceSnapshot { + let state = self.state.lock().await; + session::RuntimeWorkspaceSnapshot { + cwd: state.session_configuration.cwd().clone(), + workspace_roots: state.session_configuration.workspace_roots.clone(), + permission_profile: state.session_configuration.permission_profile(), + } + } + + pub(crate) async fn update_runtime_cwd( + &self, + turn_context: &TurnContext, + cwd: AbsolutePathBuf, + ) -> ConstraintResult<()> { + let environments = { + let state = self.state.lock().await; + let mut environments = state.session_configuration.environments.clone(); + environments.legacy_fallback_cwd = cwd.clone(); + if let Some(environment) = environments.environments.first_mut() { + environment.cwd = PathUri::from_abs_path(&cwd); + } + environments + }; + self.update_settings(SessionSettingsUpdate { + environments: Some(environments), + ..Default::default() + }) + .await?; + + let runtime_workspace = self.runtime_workspace_snapshot().await; + let turn_context_item = + turn_context.to_turn_context_item_with_runtime_workspace(&runtime_workspace); + let reference_context_item = self.reference_context_item().await; + let shell = self.user_shell(); + let exec_policy = self.services.exec_policy.current(); + let context_items = crate::context_manager::updates::build_runtime_workspace_update_items( + reference_context_item.as_ref(), + &turn_context_item, + turn_context, + shell.as_ref(), + exec_policy.as_ref(), + ); + self.record_context_items_and_set_reference_context_item( + turn_context, + context_items, + turn_context_item, + ) + .await; + self.send_event( + turn_context, + handlers::thread_settings_applied_event(self).await, + ) + .await; + Ok(()) + } + pub(crate) async fn preview_settings( &self, updates: &SessionSettingsUpdate, @@ -2168,16 +2224,36 @@ impl Session { rx_approve } + pub(crate) async fn request_permissions_for_environment( + self: &Arc, + turn_context: &Arc, + call_id: String, + args: RequestPermissionsArgs, + environment: TurnEnvironmentSelection, + cancellation_token: CancellationToken, + ) -> Option { + self.request_permissions_for_environment_with_scope( + turn_context, + call_id, + args, + environment, + /*requested_scope*/ PermissionGrantScope::Turn, + cancellation_token, + ) + .await + } + #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" )] - pub(crate) async fn request_permissions_for_environment( + async fn request_permissions_for_environment_with_scope( self: &Arc, turn_context: &Arc, call_id: String, args: RequestPermissionsArgs, environment: TurnEnvironmentSelection, + requested_scope: PermissionGrantScope, cancellation_token: CancellationToken, ) -> Option { match turn_context.as_ref().approval_policy.value() { @@ -2230,6 +2306,7 @@ impl Session { turn_id: turn_context.sub_id.clone(), reason: args.reason, permissions: requested_permissions.clone(), + requested_scope, }; let review_rx = crate::guardian::spawn_approval_request_review( session, @@ -2249,7 +2326,7 @@ impl Session { ReviewDecision::Approved | ReviewDecision::ApprovedExecpolicyAmendment { .. } => { RequestPermissionsResponse { permissions: requested_permissions.clone(), - scope: PermissionGrantScope::Turn, + scope: requested_scope, strict_auto_review: false, } } @@ -2347,6 +2424,45 @@ impl Session { args: RequestPermissionsArgs, cwd: AbsolutePathBuf, cancellation_token: CancellationToken, + ) -> Option { + self.request_permissions_for_cwd_with_scope( + turn_context, + call_id, + args, + cwd, + /*requested_scope*/ PermissionGrantScope::Turn, + cancellation_token, + ) + .await + } + + pub(crate) async fn request_session_permissions_for_cwd( + self: &Arc, + turn_context: &Arc, + call_id: String, + args: RequestPermissionsArgs, + cwd: AbsolutePathBuf, + cancellation_token: CancellationToken, + ) -> Option { + self.request_permissions_for_cwd_with_scope( + turn_context, + call_id, + args, + cwd, + /*requested_scope*/ PermissionGrantScope::Session, + cancellation_token, + ) + .await + } + + async fn request_permissions_for_cwd_with_scope( + self: &Arc, + turn_context: &Arc, + call_id: String, + args: RequestPermissionsArgs, + cwd: AbsolutePathBuf, + requested_scope: PermissionGrantScope, + cancellation_token: CancellationToken, ) -> Option { let turn_environment = match args.environment_id.as_deref() { Some(environment_id) => turn_context @@ -2365,11 +2481,12 @@ impl Session { }; let mut environment = turn_environment.selection(); environment.cwd = PathUri::from_abs_path(&cwd); - self.request_permissions_for_environment( + self.request_permissions_for_environment_with_scope( turn_context, call_id, args, environment, + requested_scope, cancellation_token, ) .await @@ -3200,6 +3317,20 @@ impl Session { .await }; let turn_context_item = turn_context.to_turn_context_item(); + self.record_context_items_and_set_reference_context_item( + turn_context, + context_items, + turn_context_item, + ) + .await; + } + + async fn record_context_items_and_set_reference_context_item( + &self, + turn_context: &TurnContext, + context_items: Vec, + turn_context_item: TurnContextItem, + ) { if !context_items.is_empty() { self.record_conversation_items(turn_context, &context_items) .await; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index dff94692e3a9..f098ec1df424 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -428,6 +428,13 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) app_server_client_version: Option, } +#[derive(Clone, Debug)] +pub(crate) struct RuntimeWorkspaceSnapshot { + pub(crate) cwd: AbsolutePathBuf, + pub(crate) workspace_roots: Vec, + pub(crate) permission_profile: PermissionProfile, +} + pub(crate) struct AppServerClientMetadata { pub(crate) client_name: Option, pub(crate) client_version: Option, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 1abf8bb5d178..0016d6f17b09 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1985,7 +1985,13 @@ async fn try_run_sampling_request( Err(err) => break Err(err), }; if let Some(tool_future) = output_result.tool_future { - in_flight.push_back(tool_future); + if output_result.tool_is_execution_barrier { + drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; + in_flight.push_back(tool_future); + drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; + } else { + in_flight.push_back(tool_future); + } } if let Some(agent_message) = output_result.last_agent_message { last_agent_message = Some(agent_message); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 70b085fc5db5..1406f5577bc2 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -320,13 +320,14 @@ impl TurnContext { .map_or_else(|| self.cwd.clone(), |path| self.cwd.join(path)) } - pub(crate) fn file_system_sandbox_context( + pub(crate) fn file_system_sandbox_context_for_permission_profile( &self, + permission_profile: &PermissionProfile, additional_permissions: Option, cwd: &PathUri, ) -> FileSystemSandboxContext { let (base_file_system_sandbox_policy, base_network_sandbox_policy) = - self.permission_profile.to_runtime_permissions(); + permission_profile.to_runtime_permissions(); let file_system_sandbox_policy = effective_file_system_sandbox_policy( &base_file_system_sandbox_policy, additional_permissions.as_ref(), @@ -336,7 +337,7 @@ impl TurnContext { additional_permissions.as_ref(), ); let permissions = PermissionProfile::from_runtime_permissions_with_enforcement( - self.permission_profile.enforcement(), + permission_profile.enforcement(), &file_system_sandbox_policy, network_sandbox_policy, ); @@ -352,6 +353,20 @@ impl TurnContext { } } + pub(crate) fn runtime_environment_cwd( + &self, + runtime_workspace: &crate::session::session::RuntimeWorkspaceSnapshot, + environment: &TurnEnvironment, + ) -> (AbsolutePathBuf, PathUri) { + if self.environments.turn_environments.len() == 1 { + let cwd = runtime_workspace.cwd.clone(); + let cwd_uri = PathUri::from_abs_path(&cwd); + (cwd, cwd_uri) + } else { + (environment.cwd().clone(), environment.cwd_uri().clone()) + } + } + fn non_legacy_file_system_sandbox_policy(&self) -> Option { // Omit the derived split filesystem policy when it is equivalent to // the legacy sandbox policy. This keeps turn-context payloads stable @@ -399,6 +414,33 @@ impl TurnContext { } } + pub(crate) fn to_turn_context_item_with_runtime_workspace( + &self, + runtime_workspace: &crate::session::session::RuntimeWorkspaceSnapshot, + ) -> TurnContextItem { + let mut item = self.to_turn_context_item(); + item.cwd = runtime_workspace.cwd.to_path_buf(); + item.workspace_roots = (!runtime_workspace.workspace_roots.is_empty()) + .then_some(runtime_workspace.workspace_roots.clone()); + item.sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( + &runtime_workspace.permission_profile, + runtime_workspace.cwd.as_path(), + ); + item.permission_profile = Some(runtime_workspace.permission_profile.clone()); + let legacy_file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &item.sandbox_policy, + runtime_workspace.cwd.as_path(), + ); + let file_system_sandbox_policy = runtime_workspace + .permission_profile + .file_system_sandbox_policy(); + item.file_system_sandbox_policy = (file_system_sandbox_policy + != legacy_file_system_sandbox_policy) + .then_some(file_system_sandbox_policy); + item + } + fn turn_context_network_item(&self) -> Option { let network = self .config diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 9ae20df74dfa..6f266534d2e7 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -311,6 +311,7 @@ pub(crate) struct OutputItemResult { pub last_agent_message: Option, pub needs_follow_up: bool, pub tool_future: Option>, + pub tool_is_execution_barrier: bool, } pub(crate) struct HandleOutputCtx { @@ -432,6 +433,7 @@ pub(crate) async fn handle_output_item_done( record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item) .await; + output.tool_is_execution_barrier = ctx.tool_runtime.tool_is_execution_barrier(&call); let cancellation_token = ctx.cancellation_token.child_token(); let tool_future: InFlightFuture<'static> = Box::pin( ctx.tool_runtime diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index a719e9acd9f6..9a95a4e083b6 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -265,7 +265,7 @@ fn apply_patch_payload_command(payload: &ToolPayload) -> Option { async fn effective_patch_permissions( session: &Session, - turn: &TurnContext, + permission_profile: &codex_protocol::models::PermissionProfile, environment_id: &str, action: &ApplyPatchAction, cwd: &AbsolutePathBuf, @@ -285,7 +285,7 @@ async fn effective_patch_permissions( .await .as_ref(), ); - let base_file_system_sandbox_policy = turn.file_system_sandbox_policy(); + let base_file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); let file_system_sandbox_policy = effective_file_system_sandbox_policy( &base_file_system_sandbox_policy, granted_permissions.as_ref(), @@ -359,11 +359,13 @@ impl ApplyPatchHandler { "apply_patch is unavailable in this session".to_string(), )); }; - let cwd = turn_environment.cwd().clone(); + let runtime_workspace = session.runtime_workspace_snapshot().await; + let (cwd, sandbox_cwd) = turn.runtime_environment_cwd(&runtime_workspace, turn_environment); let fs = turn_environment.environment.get_filesystem(); - let sandbox = turn.file_system_sandbox_context( + let sandbox = turn.file_system_sandbox_context_for_permission_profile( + &runtime_workspace.permission_profile, /*additional_permissions*/ None, - turn_environment.cwd_uri(), + &sandbox_cwd, ); match codex_apply_patch::verify_apply_patch_args(args, &cwd, fs.as_ref(), Some(&sandbox)) .await @@ -372,7 +374,7 @@ impl ApplyPatchHandler { let (file_paths, effective_additional_permissions, file_system_sandbox_policy) = effective_patch_permissions( session.as_ref(), - turn.as_ref(), + &runtime_workspace.permission_profile, &turn_environment.environment_id, &changes, &cwd, @@ -527,8 +529,12 @@ pub(crate) async fn intercept_apply_patch( tool_name: &str, ) -> Result, FunctionCallError> { let sandbox_cwd = PathUri::from_abs_path(cwd); - let sandbox = - turn.file_system_sandbox_context(/*additional_permissions*/ None, &sandbox_cwd); + let runtime_workspace = session.runtime_workspace_snapshot().await; + let sandbox = turn.file_system_sandbox_context_for_permission_profile( + &runtime_workspace.permission_profile, + /*additional_permissions*/ None, + &sandbox_cwd, + ); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, Some(&sandbox)) .await { @@ -536,7 +542,7 @@ pub(crate) async fn intercept_apply_patch( let (approval_keys, effective_additional_permissions, file_system_sandbox_policy) = effective_patch_permissions( session.as_ref(), - turn.as_ref(), + &runtime_workspace.permission_profile, &turn_environment.environment_id, &changes, cwd, diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 33625317fe40..6563e5321b07 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -112,12 +112,16 @@ impl TurnItemEmitter for CoreTurnItemEmitter { async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { let conversation_history = ConversationHistory::new(invocation.session.clone_history().await.into_raw_items()); + let runtime_workspace = invocation.session.runtime_workspace_snapshot().await; let mut environments = Vec::with_capacity(invocation.turn.environments.turn_environments.len()); for environment in &invocation.turn.environments.turn_environments { + let (cwd, cwd_uri) = invocation + .turn + .runtime_environment_cwd(&runtime_workspace, environment); let additional_permissions = apply_granted_turn_permissions( invocation.session.as_ref(), &environment.environment_id, - environment.cwd().as_path(), + cwd.as_path(), SandboxPermissions::UseDefault, /*additional_permissions*/ None, ) @@ -125,10 +129,14 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { .additional_permissions; let file_system_sandbox_context = invocation .turn - .file_system_sandbox_context(additional_permissions, environment.cwd_uri()); + .file_system_sandbox_context_for_permission_profile( + &runtime_workspace.permission_profile, + additional_permissions, + &cwd_uri, + ); environments.push(ToolEnvironment { environment_id: environment.environment_id.clone(), - cwd: environment.cwd().clone(), + cwd, file_system: environment.environment.get_filesystem(), file_system_sandbox_context, }); diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 258c9914ddd5..cd23e1367a57 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -24,6 +24,7 @@ mod request_plugin_install; pub(crate) mod request_plugin_install_spec; mod request_user_input; pub(crate) mod request_user_input_spec; +mod set_working_directory; mod shell; pub(crate) mod shell_spec; mod test_sync; @@ -66,6 +67,7 @@ pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_plugin_install::RequestPluginInstallHandler; pub use request_user_input::RequestUserInputHandler; +pub(crate) use set_working_directory::SetWorkingDirectoryHandler; pub use shell::ShellCommandHandler; pub(crate) use shell::ShellCommandHandlerOptions; pub use test_sync::TestSyncHandler; diff --git a/codex-rs/core/src/tools/handlers/set_working_directory.rs b/codex-rs/core/src/tools/handlers/set_working_directory.rs new file mode 100644 index 000000000000..ec96d47e51d4 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/set_working_directory.rs @@ -0,0 +1,323 @@ +use crate::function_tool::FunctionCallError; +use crate::session::session::RuntimeWorkspaceSnapshot; +use crate::session::session::Session; +use crate::session::session::SessionSettingsUpdate; +use crate::session::turn_context::TurnContext; +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::registry::CoreToolRuntime; +use crate::tools::registry::ToolExecutionPolicy; +use crate::tools::registry::ToolExecutor; +use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::FileSystemSandboxContext; +use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::models::FileSystemPermissions; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::protocol::TurnEnvironmentSelections; +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; +use codex_protocol::request_permissions::RequestPermissionsArgs; +use codex_sandboxing::policy_transforms::intersect_permission_profiles; +use codex_tools::JsonSchema; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolName; +use codex_tools::ToolSpec; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::io; +use std::path::Path; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; + +pub(crate) struct SetWorkingDirectoryHandler; + +#[derive(Deserialize)] +struct SetWorkingDirectoryArgs { + path: String, +} + +impl ToolExecutor for SetWorkingDirectoryHandler { + fn tool_name(&self) -> ToolName { + ToolName::plain("set_working_directory") + } + + fn spec(&self) -> ToolSpec { + ToolSpec::Function(ResponsesApiTool { + name: "set_working_directory".to_string(), + description: "Change this session's working directory. Relative paths resolve from the current working directory. Later tool calls in the same response wait for this change and are cancelled if it fails.".to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + BTreeMap::from([( + "path".to_string(), + JsonSchema::string( + /*description*/ Some("Existing directory path.".to_string()), + ), + )]), + /*required*/ Some(vec!["path".to_string()]), + /*additional_properties*/ Some(false.into()), + ), + output_schema: None, + }) + } + + fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> { + Box::pin(self.handle_call(invocation)) + } +} + +impl SetWorkingDirectoryHandler { + async fn handle_call( + &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( + "set_working_directory received unsupported payload".to_string(), + )); + } + }; + let args: SetWorkingDirectoryArgs = parse_arguments(&arguments)?; + let current = session.runtime_workspace_snapshot().await; + let requested = current.cwd.join(args.path); + let [environment] = turn.environments.turn_environments.as_slice() else { + return Err(FunctionCallError::RespondToModel( + "set_working_directory requires exactly one execution environment".to_string(), + )); + }; + let fs = environment.environment.get_filesystem(); + let preview = preview_cwd(&session, environment.selection(), requested.clone()).await?; + let inspection_permissions = match required_permissions(¤t, &preview) { + Some(requested_permissions) => Some( + request_session_permissions( + &session, + &turn, + call_id, + requested.clone(), + requested_permissions, + ¤t, + cancellation_token, + ) + .await?, + ), + None => None, + }; + let sandbox_cwd = PathUri::from_abs_path(¤t.cwd); + let sandbox = turn.file_system_sandbox_context_for_permission_profile( + ¤t.permission_profile, + inspection_permissions.clone().map(Into::into), + &sandbox_cwd, + ); + let canonical = resolve_directory(fs.as_ref(), &requested, &sandbox) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("cannot change working directory: {err}")) + })?; + + if canonical == current.cwd { + return set_working_directory_success(canonical); + } + + let preview = preview_cwd(&session, environment.selection(), canonical.clone()).await?; + if !preview + .permission_profile + .file_system_sandbox_policy() + .can_read_path_with_cwd(canonical.as_path(), canonical.as_path()) + { + return Err(FunctionCallError::RespondToModel( + "the requested directory is unavailable under the active permission profile" + .to_string(), + )); + } + if required_permissions(¤t, &preview).is_some_and(|requested_permissions| { + !inspection_permissions.as_ref().is_some_and(|granted| { + permissions_are_approved( + requested_permissions, + granted.clone(), + current.cwd.as_path(), + ) + }) + }) { + return Err(FunctionCallError::RespondToModel( + "the canonical directory requires filesystem access outside the approved path" + .to_string(), + )); + } + + session + .update_runtime_cwd(turn.as_ref(), canonical.clone()) + .await + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?; + set_working_directory_success(canonical) + } +} + +impl CoreToolRuntime for SetWorkingDirectoryHandler { + fn execution_policy(&self) -> ToolExecutionPolicy { + ToolExecutionPolicy::BarrierAndCancelSuffix + } +} + +async fn preview_cwd( + session: &Session, + environment: codex_protocol::protocol::TurnEnvironmentSelection, + cwd: AbsolutePathBuf, +) -> Result { + session + .preview_settings(&SessionSettingsUpdate { + environments: Some(TurnEnvironmentSelections::new(cwd, vec![environment])), + ..Default::default() + }) + .await + .map_err(|err| FunctionCallError::RespondToModel(err.to_string())) +} + +async fn request_session_permissions( + session: &Arc, + turn: &Arc, + call_id: String, + target: AbsolutePathBuf, + requested_permissions: RequestPermissionProfile, + current: &RuntimeWorkspaceSnapshot, + cancellation_token: CancellationToken, +) -> Result { + let response = session + .request_session_permissions_for_cwd( + turn, + call_id, + RequestPermissionsArgs { + environment_id: None, + reason: Some(format!( + "switch this session's working directory to `{}`", + target.as_path().display() + )), + permissions: requested_permissions.clone(), + }, + current.cwd.clone(), + cancellation_token, + ) + .await + .ok_or_else(|| { + FunctionCallError::RespondToModel( + "working directory approval was cancelled".to_string(), + ) + })?; + if !matches!(response.scope, PermissionGrantScope::Session) + || !permissions_are_approved( + requested_permissions, + response.permissions.clone(), + current.cwd.as_path(), + ) + { + return Err(FunctionCallError::RespondToModel( + "changing the working directory requires session-scoped filesystem approval" + .to_string(), + )); + } + Ok(response.permissions) +} + +fn required_permissions( + current: &RuntimeWorkspaceSnapshot, + preview: &crate::codex_thread::ThreadConfigSnapshot, +) -> Option { + newly_accessible_roots( + ¤t.permission_profile.file_system_sandbox_policy(), + current.cwd.as_path(), + &preview.permission_profile.file_system_sandbox_policy(), + preview.cwd().as_path(), + ) + .map(|file_system| RequestPermissionProfile { + file_system: Some(file_system), + network: None, + }) +} + +fn newly_accessible_roots( + current_policy: &FileSystemSandboxPolicy, + current_cwd: &Path, + preview_policy: &FileSystemSandboxPolicy, + preview_cwd: &Path, +) -> Option { + let write = preview_policy + .get_writable_roots_with_cwd(preview_cwd) + .into_iter() + .map(|root| root.root) + .filter(|root| !current_policy.can_write_path_with_cwd(root.as_path(), current_cwd)) + .collect::>(); + let read = preview_policy + .get_readable_roots_with_cwd(preview_cwd) + .into_iter() + .filter(|root| !current_policy.can_read_path_with_cwd(root.as_path(), current_cwd)) + .filter(|root| { + !write + .iter() + .any(|writable_root| root.as_path().starts_with(writable_root.as_path())) + }) + .collect::>(); + if read.is_empty() && write.is_empty() { + None + } else { + Some(FileSystemPermissions::from_read_write_roots( + /*read*/ (!read.is_empty()).then_some(read), + /*write*/ (!write.is_empty()).then_some(write), + )) + } +} + +fn permissions_are_approved( + requested: RequestPermissionProfile, + granted: RequestPermissionProfile, + cwd: &Path, +) -> bool { + let requested: AdditionalPermissionProfile = requested.into(); + let granted: AdditionalPermissionProfile = granted.into(); + intersect_permission_profiles(requested.clone(), granted, cwd) == requested +} + +async fn resolve_directory( + fs: &dyn ExecutorFileSystem, + requested: &AbsolutePathBuf, + sandbox: &FileSystemSandboxContext, +) -> io::Result { + let requested = PathUri::from_abs_path(requested); + let canonical = fs.canonicalize(&requested, Some(sandbox)).await?; + let metadata = fs.get_metadata(&canonical, Some(sandbox)).await?; + let canonical = canonical.to_abs_path()?; + if metadata.is_directory { + Ok(canonical) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "working directory target is not a directory: {}", + canonical.as_path().display() + ), + )) + } +} + +fn set_working_directory_success( + cwd: AbsolutePathBuf, +) -> Result, FunctionCallError> { + let content = serde_json::json!({ "cwd": cwd }).to_string(); + Ok(boxed_tool_output(FunctionToolOutput::from_text( + content, + /*success*/ Some(true), + ))) +} diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index b978a2f577a8..e54f89702655 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -78,6 +78,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result Result { let shell = session.user_shell(); let use_login_shell = Self::resolve_use_login_shell(params.login, allow_login_shell)?; let command = Self::base_command(shell.as_ref(), ¶ms.command, use_login_shell); - #[allow(deprecated)] - let cwd = turn_context.resolve_path(params.workdir.clone()); + let cwd = params + .workdir + .as_ref() + .map_or_else(|| cwd.clone(), |path| cwd.join(path)); Ok(ExecParams { command, @@ -167,11 +170,13 @@ impl ShellCommandHandler { ))); }; - #[allow(deprecated)] - let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; + let runtime_workspace = session.runtime_workspace_snapshot().await; + let cwd = resolve_workdir_base_path(&arguments, &runtime_workspace.cwd)?; let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; - #[allow(deprecated)] - let workdir = turn.resolve_path(params.workdir.clone()); + let workdir = params.workdir.as_ref().map_or_else( + || runtime_workspace.cwd.clone(), + |path| runtime_workspace.cwd.join(path), + ); maybe_emit_implicit_skill_invocation( session.as_ref(), turn.as_ref(), @@ -184,6 +189,7 @@ impl ShellCommandHandler { ¶ms, session.as_ref(), turn.as_ref(), + &runtime_workspace.cwd, session.thread_id, turn.config.permissions.allow_login_shell, )?; diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 18dbc2b7599f..c2bbc86d856f 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -102,6 +102,11 @@ async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_contex ¶ms, &session, &turn_context, + turn_context + .environments + .primary() + .expect("primary environment") + .cwd(), session.thread_id, /*allow_login_shell*/ true, ) @@ -164,6 +169,11 @@ async fn shell_command_handler_defaults_to_non_login_when_disallowed() { ¶ms, &session, &turn_context, + turn_context + .environments + .primary() + .expect("primary environment") + .cwd(), session.thread_id, /*allow_login_shell*/ false, ) diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 92a37ae10d01..d08fc0a8f701 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -122,6 +122,7 @@ impl ExecCommandHandler { let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager; let context = UnifiedExecContext::new(session.clone(), turn.clone(), call_id.clone()); let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?; + let runtime_workspace = session.runtime_workspace_snapshot().await; let Some(turn_environment) = resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())? else { @@ -129,14 +130,12 @@ impl ExecCommandHandler { "unified exec is unavailable in this session".to_string(), )); }; + let (base_cwd, _) = turn.runtime_environment_cwd(&runtime_workspace, turn_environment); let cwd = environment_args .workdir .as_deref() .filter(|workdir| !workdir.is_empty()) - .map_or_else( - || turn_environment.cwd().clone(), - |workdir| turn_environment.cwd().join(workdir), - ); + .map_or_else(|| base_cwd.clone(), |workdir| base_cwd.join(workdir)); let environment = Arc::clone(&turn_environment.environment); let fs = environment.get_filesystem(); let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; @@ -277,7 +276,8 @@ impl ExecCommandHandler { yield_time_ms, max_output_tokens, cwd, - sandbox_cwd: turn_environment.cwd().clone(), + sandbox_cwd: base_cwd, + permission_profile: runtime_workspace.permission_profile, environment, shell_mode, network: context.turn.network.clone(), diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index dc52e7e336c7..6d475ce915a1 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -143,11 +143,13 @@ impl ViewImageHandler { "view_image is unavailable in this session".to_string(), )); }; - let cwd = turn_environment.cwd().clone(); + let runtime_workspace = session.runtime_workspace_snapshot().await; + let (cwd, sandbox_cwd) = turn.runtime_environment_cwd(&runtime_workspace, turn_environment); let abs_path = cwd.join(path); - let sandbox = turn.file_system_sandbox_context( + let sandbox = turn.file_system_sandbox_context_for_permission_profile( + &runtime_workspace.permission_profile, /*additional_permissions*/ None, - turn_environment.cwd_uri(), + &sandbox_cwd, ); let fs = turn_environment.environment.get_filesystem(); let path_uri = PathUri::from_abs_path(&abs_path); @@ -265,12 +267,14 @@ impl ToolOutput for ViewImageOutput { #[cfg(test)] mod tests { use super::*; + use crate::session::session::SessionSettingsUpdate; use crate::session::tests::make_session_and_context; use crate::session::turn_context::TurnEnvironment; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::models::PermissionProfile; + use codex_protocol::protocol::TurnEnvironmentSelections; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; @@ -278,19 +282,35 @@ mod tests { use std::sync::Arc; use tokio::sync::Mutex; - fn replace_primary_environment_cwd(turn: &mut crate::TurnContext, cwd: AbsolutePathBuf) { + async fn replace_runtime_workspace( + session: &crate::session::session::Session, + turn: &mut crate::TurnContext, + cwd: AbsolutePathBuf, + permission_profile: PermissionProfile, + ) { let current = turn .environments .turn_environments .first() .cloned() .expect("default local turn environment"); - turn.environments.turn_environments[0] = TurnEnvironment::new( + let environment = TurnEnvironment::new( current.environment_id, current.environment, - cwd, + cwd.clone(), current.shell, ); + let selection = environment.selection(); + turn.environments.turn_environments[0] = environment; + turn.permission_profile = permission_profile.clone(); + session + .update_settings(SessionSettingsUpdate { + environments: Some(TurnEnvironmentSelections::new(cwd, vec![selection])), + permission_profile: Some(permission_profile), + ..Default::default() + }) + .await + .expect("update runtime workspace"); } #[test] @@ -329,10 +349,15 @@ mod tests { let image_dir = tempfile::tempdir().expect("create image temp dir"); let image_cwd = image_dir.abs(); - replace_primary_environment_cwd(&mut turn, image_cwd.clone()); + replace_runtime_workspace( + &session, + &mut turn, + image_cwd.clone(), + PermissionProfile::read_only(), + ) + .await; let image_path = image_cwd.join("image.png"); std::fs::write(image_path.as_path(), b"not a real image").expect("write test image"); - turn.permission_profile = PermissionProfile::read_only(); let result = ViewImageHandler::default() .handle(ToolInvocation { @@ -392,10 +417,15 @@ mod tests { let image_dir = tempfile::tempdir().expect("create image temp dir"); let image_cwd = image_dir.abs(); - replace_primary_environment_cwd(&mut turn, image_cwd.clone()); + replace_runtime_workspace( + &session, + &mut turn, + image_cwd.clone(), + PermissionProfile::Disabled, + ) + .await; let image_path = image_cwd.join("image.png"); std::fs::write(image_path.as_path(), b"not a real image").expect("write test image"); - turn.permission_profile = PermissionProfile::Disabled; let result = ViewImageHandler::default() .handle(ToolInvocation { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 7e97e4ad5144..ada1498db8f9 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -149,7 +149,10 @@ impl ToolOrchestrator { // 1) Approval let mut already_approved = false; - let file_system_sandbox_policy = turn_ctx.file_system_sandbox_policy(); + let runtime_workspace = tool_ctx.session.runtime_workspace_snapshot().await; + let file_system_sandbox_policy = runtime_workspace + .permission_profile + .file_system_sandbox_policy(); let network_sandbox_policy = turn_ctx.network_sandbox_policy(); let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| { default_exec_approval_requirement(approval_policy, &file_system_sandbox_policy) @@ -238,13 +241,12 @@ impl ToolOrchestrator { // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.features.use_legacy_landlock(); - #[allow(deprecated)] - let sandbox_cwd = tool.sandbox_cwd(req).unwrap_or(&turn_ctx.cwd); + let sandbox_cwd = tool.sandbox_cwd(req).unwrap_or(&runtime_workspace.cwd); let sandbox_policy_cwd = PathUri::from_abs_path(sandbox_cwd); - let workspace_roots = turn_ctx.config.effective_workspace_roots(); + let workspace_roots = runtime_workspace.workspace_roots; let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, - permissions: &turn_ctx.permission_profile, + permissions: &runtime_workspace.permission_profile, enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index c40886b56cb3..f1f7e4d081e4 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::Mutex; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Instant; @@ -34,6 +35,7 @@ pub(crate) struct ToolCallRuntime { turn_context: Arc, tracker: SharedTurnDiffTracker, parallel_execution: Arc>, + failed_barrier_call_id: Arc>>, } impl ToolCallRuntime { @@ -49,9 +51,14 @@ impl ToolCallRuntime { turn_context, tracker, parallel_execution: Arc::new(RwLock::new(())), + failed_barrier_call_id: Arc::new(Mutex::new(/*t*/ None)), } } + pub(crate) fn tool_is_execution_barrier(&self, call: &ToolCall) -> bool { + self.router.tool_execution_policy(call).is_barrier() + } + pub(crate) fn create_diff_consumer( &self, tool_name: &codex_tools::ToolName, @@ -66,13 +73,59 @@ impl ToolCallRuntime { cancellation_token: CancellationToken, ) -> impl std::future::Future> { let error_call = call.clone(); - let future = - self.handle_tool_call_with_source(call, ToolCallSource::Direct, cancellation_token); + let failed_barrier_call_id = Arc::clone(&self.failed_barrier_call_id); + let cancels_suffix = self + .router + .tool_execution_policy(&call) + .cancels_suffix_on_failure(); + let barrier_call_id = cancels_suffix.then(|| error_call.call_id.clone()); async move { - match future.await { - Ok(response) => Ok(response.into_response()), - Err(FunctionCallError::Fatal(message)) => Err(CodexErr::Fatal(message)), - Err(other) => Ok(Self::failure_response(error_call, other)), + let prior_failure = failed_barrier_call_id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + if let Some(failed_call_id) = prior_failure { + return Ok(Self::failure_response( + error_call, + FunctionCallError::RespondToModel( + serde_json::json!({ + "code": "dependency_cancelled", + "message": format!( + "cancelled because set_working_directory `{failed_call_id}` failed" + ), + "failed_call_id": failed_call_id, + }) + .to_string(), + ), + )); + } + match self + .handle_tool_call_with_source(call, ToolCallSource::Direct, cancellation_token) + .await + { + Ok(result) => { + if !result.result.success_for_logging() { + record_failed_barrier_call( + failed_barrier_call_id.as_ref(), + barrier_call_id.as_ref(), + ); + } + Ok(result.into_response()) + } + Err(FunctionCallError::Fatal(message)) => { + record_failed_barrier_call( + failed_barrier_call_id.as_ref(), + barrier_call_id.as_ref(), + ); + Err(CodexErr::Fatal(message)) + } + Err(other) => { + record_failed_barrier_call( + failed_barrier_call_id.as_ref(), + barrier_call_id.as_ref(), + ); + Ok(Self::failure_response(error_call, other)) + } } } .in_current_span() @@ -235,6 +288,14 @@ impl ToolCallRuntime { } } +fn record_failed_barrier_call(failed_call_id: &Mutex>, call_id: Option<&String>) { + if let Some(call_id) = call_id { + *failed_call_id + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(call_id.clone()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index c027bd16b98d..0babcaf3387f 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -40,11 +40,32 @@ pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>; pub use codex_tools::ToolExecutor; pub use codex_tools::ToolExposure; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum ToolExecutionPolicy { + #[default] + Parallel, + BarrierAndCancelSuffix, +} + +impl ToolExecutionPolicy { + pub(crate) fn is_barrier(self) -> bool { + !matches!(self, Self::Parallel) + } + + pub(crate) fn cancels_suffix_on_failure(self) -> bool { + matches!(self, Self::BarrierAndCancelSuffix) + } +} + /// Typed runtime contract for locally executed tools. /// /// Implementers provide the shared `ToolExecutor` behavior plus optional /// core-owned metadata for hooks, telemetry, tool search, and argument diffs. pub(crate) trait CoreToolRuntime: ToolExecutor { + fn execution_policy(&self) -> ToolExecutionPolicy { + ToolExecutionPolicy::Parallel + } + fn matches_kind(&self, payload: &ToolPayload) -> bool { matches!( payload, @@ -277,6 +298,10 @@ impl ToolExecutor for ExposureOverride { } impl CoreToolRuntime for ExposureOverride { + fn execution_policy(&self) -> ToolExecutionPolicy { + self.handler.execution_policy() + } + fn matches_kind(&self, payload: &ToolPayload) -> bool { self.handler.matches_kind(payload) } @@ -387,6 +412,10 @@ impl ToolRegistry { Some(tool.waits_for_runtime_cancellation()) } + pub(crate) fn execution_policy(&self, name: &ToolName) -> Option { + self.tool(name).map(|tool| tool.execution_policy()) + } + #[allow(dead_code)] pub(crate) async fn dispatch_any( &self, diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index a095499ea09c..7f9e3498bc6c 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -92,6 +92,15 @@ impl ToolRouter { .unwrap_or(false) } + pub(crate) fn tool_execution_policy( + &self, + call: &ToolCall, + ) -> crate::tools::registry::ToolExecutionPolicy { + self.registry + .execution_policy(&call.tool_name) + .unwrap_or_default() + } + #[instrument(level = "trace", skip_all, err)] pub fn build_tool_call(item: ResponseItem) -> Result, FunctionCallError> { match item { diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index f096cddd80be..711c6f017d89 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -20,6 +20,7 @@ use crate::tools::handlers::ReadMcpResourceHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestPluginInstallHandler; use crate::tools::handlers::RequestUserInputHandler; +use crate::tools::handlers::SetWorkingDirectoryHandler; use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellCommandHandlerOptions; use crate::tools::handlers::TestSyncHandler; @@ -656,6 +657,20 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut planned_tools.add(RequestPermissionsHandler); } + if matches!(environment_mode, ToolEnvironmentMode::Single) { + let handler = SetWorkingDirectoryHandler; + let tool_name = handler.tool_name(); + let has_dynamic_tool_with_same_name = context.dynamic_tools.iter().any(|spec| match spec { + DynamicToolSpec::Function(tool) => { + tool.name == tool_name.name && DynamicToolHandler::new(tool).is_some() + } + DynamicToolSpec::Namespace(_) => false, + }); + if !has_dynamic_tool_with_same_name { + planned_tools.add_with_exposure(handler, ToolExposure::DirectModelOnly); + } + } + if features.enabled(Feature::TokenBudget) { planned_tools.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly); planned_tools.add(GetContextRemainingHandler); diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 0cdc5678556e..c7314f940b6d 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -408,6 +408,58 @@ fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> Dyn } } +#[tokio::test] +async fn dynamic_tool_only_overrides_exact_set_working_directory_name() { + let tool_name = "set_working_directory"; + let dynamic_override = probe_with( + |_| {}, + ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + /*namespace*/ None, tool_name, /*defer_loading*/ false, + )], + ..ToolPlanInputs::default() + }, + ) + .await; + + let ToolSpec::Function(ResponsesApiTool { description, .. }) = + dynamic_override.visible_spec(tool_name) + else { + panic!("expected dynamic function tool"); + }; + assert_eq!(description, "set_working_directory dynamic tool"); + assert_eq!(dynamic_override.exposure(tool_name), ToolExposure::Direct); + assert_eq!( + dynamic_override + .registered_names + .iter() + .filter(|registered| registered.as_str() == tool_name) + .count(), + 1 + ); + + let namespaced_tool = probe_with( + |_| {}, + ToolPlanInputs { + dynamic_tools: vec![dynamic_tool( + Some("external"), + tool_name, + /*defer_loading*/ false, + )], + ..ToolPlanInputs::default() + }, + ) + .await; + assert_eq!( + namespaced_tool.exposure(tool_name), + ToolExposure::DirectModelOnly + ); + assert_eq!( + namespaced_tool.namespace_function_names("external"), + &[tool_name.to_string()] + ); +} + fn discoverable_plugin(id: &str, name: &str) -> DiscoverableTool { DiscoverablePluginInfo { id: id.to_string(), @@ -623,14 +675,19 @@ async fn environment_count_controls_environment_backed_tools() { "exec_command", "apply_patch", "view_image", + "set_working_directory", ]); no_environment.assert_registered_lacks(&[ "shell_command", "exec_command", "apply_patch", "view_image", + "set_working_directory", ]); + let single_environment = probe(|_| {}).await; + single_environment.assert_visible_contains(&["set_working_directory"]); + let multiple_environments = probe(|turn| { duplicate_primary_environment(turn); set_feature(turn, Feature::ShellTool, /*enabled*/ true); @@ -639,6 +696,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"]); assert!(has_parameter( multiple_environments.visible_spec("exec_command"), "environment_id" @@ -1267,6 +1325,7 @@ async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { "exec", "wait", "request_user_input", + "set_working_directory", "agents", // Hosted Responses tools. "web_search", @@ -1353,6 +1412,7 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { codex_code_mode::PUBLIC_TOOL_NAME, codex_code_mode::WAIT_TOOL_NAME, "request_user_input", + "set_working_directory", // Multi-agent v2 tools. "spawn_agent", "send_message", diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 65b18b064dcb..a1fc8929b096 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -30,6 +30,7 @@ use std::sync::Weak; use codex_exec_server::Environment; use codex_network_proxy::NetworkProxy; use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::models::PermissionProfile; use codex_tools::UnifiedExecShellMode; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; @@ -97,6 +98,7 @@ pub(crate) struct ExecCommandRequest { pub max_output_tokens: Option, pub cwd: AbsolutePathBuf, pub sandbox_cwd: AbsolutePathBuf, + pub permission_profile: PermissionProfile, pub environment: Arc, pub shell_mode: UnifiedExecShellMode, pub network: Option, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a44cc5253d92..6814020b79b7 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1051,7 +1051,7 @@ impl UnifiedExecProcessManager { .create_exec_approval_requirement_for_command(ExecApprovalRequest { command: &request.command, approval_policy: context.turn.approval_policy.value(), - permission_profile: context.turn.permission_profile(), + permission_profile: request.permission_profile.clone(), windows_sandbox_level: context.turn.windows_sandbox_level, sandbox_permissions: if request.additional_permissions_preapproved { crate::sandboxing::SandboxPermissions::UseDefault diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index d9b3a771b492..eecebe93a3bd 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -176,6 +176,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { cwd: turn.cwd.clone(), #[allow(deprecated)] sandbox_cwd: turn.cwd.clone(), + permission_profile: turn.permission_profile(), environment: turn .environments .primary_environment() diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 670b0fa1a924..dc854990cbf8 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -506,6 +506,7 @@ async fn code_mode_only_restricts_prompt_tools() -> Result<()> { "exec".to_string(), "wait".to_string(), "request_user_input".to_string(), + "set_working_directory".to_string(), "web_search".to_string() ] ); @@ -593,6 +594,7 @@ if (!tool) { "exec".to_string(), "wait".to_string(), "request_user_input".to_string(), + "set_working_directory".to_string(), "web_search".to_string(), "image_generation".to_string() ] diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 3ee758372d22..ece1f76a67f8 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -174,6 +174,7 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> { codex_code_mode::PUBLIC_TOOL_NAME.to_string(), codex_code_mode::WAIT_TOOL_NAME.to_string(), "request_user_input".to_string(), + "set_working_directory".to_string(), // Hosted Responses tools. "web_search".to_string(), "image_generation".to_string(), diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 809cb0ea3276..3e6f5fa5f34c 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -193,6 +193,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { expected_tools_names.extend([ "update_plan", "request_user_input", + "set_working_directory", "apply_patch", "view_image", "tool_search", diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index fd19a18c76c8..f587f888d773 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -9,6 +9,7 @@ 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::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -16,6 +17,8 @@ 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_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; @@ -30,6 +33,7 @@ use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; use regex_lite::Regex; use serde_json::Value; use serde_json::json; @@ -126,6 +130,125 @@ 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:?}" ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn set_working_directory_auto_review_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") + .with_config(|config| { + config.workspace_roots = vec![config.cwd.clone()]; + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + config + .features + .enable(Feature::GuardianApproval) + .expect("guardian approval should enable for test"); + }); + let test = builder.build(&server).await?; + let external_cwd = tempfile::tempdir()?; + let external_cwd = AbsolutePathBuf::try_from(external_cwd.path().canonicalize()?)?; + let set_cwd_call_id = "set-external-cwd"; + let shell_call_id = "pwd-after-set-cwd"; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + set_cwd_call_id, + "set_working_directory", + &serde_json::to_string(&json!({ "path": external_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"), + ]), + sse(vec![ + ev_response_created("resp-guardian"), + ev_assistant_message( + "msg-guardian", + &json!({ + "risk_level": "low", + "user_authorization": "high", + "outcome": "allow", + "rationale": "The requested session-scoped cwd change matches the user request.", + }) + .to_string(), + ), + ev_completed("resp-guardian"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let permission_profile = PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, + ); + test.submit_turn_with_approval_and_permission_profile( + "change to the external directory and print the working directory", + AskForApproval::OnRequest, + permission_profile, + ) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let set_cwd_output = responses + .function_call_output_text(set_cwd_call_id) + .expect("set cwd output"); + assert_eq!( + serde_json::from_str::(&set_cwd_output)?, + json!({ + "cwd": external_cwd, + }) + ); + let shell_output = responses + .function_call_output_text(shell_call_id) + .expect("shell output"); + assert!(shell_output.contains(external_cwd.as_path().to_string_lossy().as_ref())); + let followup_request = responses + .requests() + .into_iter() + .find(|request| request.function_call_output_text(shell_call_id).is_some()) + .expect("model request after cwd change"); + let expected_cwd = external_cwd.as_path().to_string_lossy(); + assert!( + followup_request.has_message_with_input_texts("user", |texts| { + texts.iter().any(|text| { + text.contains("") && text.contains(expected_cwd.as_ref()) + }) + }) + ); + let guardian_request = responses + .requests() + .into_iter() + .find(|request| request.body_contains_text("request_permissions")) + .expect("guardian request"); + assert!(guardian_request.body_contains_text("\"requested_scope\": \"session\"")); Ok(()) }