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 ed4b81205a..ef837d88ec 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 @@ -142,26 +142,48 @@ impl ExecCommandHandler { turn_environment.path_convention(), ) .map_err(FunctionCallError::RespondToModel)?; - let base_cwd = turn_environment.compatible_cwd().ok_or_else(|| { - FunctionCallError::RespondToModel( - "cross-platform unified exec requires native remote routing".to_string(), + let is_remote = environment.is_remote(); + let (cwd, sandbox_cwd, args) = if is_remote { + let raw_arguments: serde_json::Value = parse_arguments(&arguments)?; + if raw_arguments + .get("additional_permissions") + .is_some_and(|permissions| !permissions.is_null()) + { + return Err(FunctionCallError::RespondToModel( + "remote exec does not support per-command permission elevation until it can be enforced by the exec-server".to_string(), + )); + } + #[allow(deprecated)] + let compatibility_cwd = turn.cwd.clone(); + ( + compatibility_cwd, + None, + parse_arguments::(&arguments)?, ) - })?; - let cwd = cwd_uri.to_abs_path().map_err(|error| { - FunctionCallError::RespondToModel(format!( - "working directory `{cwd_uri}` cannot be used on the app-server host: {error}" - )) - })?; - let fs = environment.get_filesystem(); - let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?; + } else { + let sandbox_cwd = turn_environment.compatible_cwd().ok_or_else(|| { + FunctionCallError::RespondToModel( + "selected local environment has an incompatible cwd".to_string(), + ) + })?; + let cwd = cwd_uri.to_abs_path().map_err(|error| { + FunctionCallError::RespondToModel(format!( + "working directory `{cwd_uri}` cannot be used on the app-server host: {error}" + )) + })?; + let args = parse_arguments_with_base_path(&arguments, &cwd)?; + (cwd, Some(sandbox_cwd), args) + }; let hook_command = args.cmd.clone(); - maybe_emit_implicit_skill_invocation( - session.as_ref(), - context.turn.as_ref(), - &hook_command, - &cwd, - ) - .await; + if !is_remote { + maybe_emit_implicit_skill_invocation( + session.as_ref(), + context.turn.as_ref(), + &hook_command, + &cwd, + ) + .await; + } let shell_mode = shell_mode_for_environment(&turn.unified_exec_shell_mode, environment.as_ref()); let (default_shell, shell_resolution) = if environment.is_remote() { @@ -183,7 +205,6 @@ impl ExecCommandHandler { let command = resolved_command.command; let shell_type = resolved_command.shell_type; let command_for_display = codex_shell_command::parse_command::shlex_join(&command); - let process_id = manager.allocate_process_id().await; let ExecCommandArgs { tty, @@ -196,6 +217,43 @@ impl ExecCommandHandler { .. } = args; + if is_remote { + if sandbox_permissions.requests_sandbox_override() || additional_permissions.is_some() { + return Err(FunctionCallError::RespondToModel( + "remote exec does not support per-command permission elevation until it can be enforced by the exec-server".to_string(), + )); + } + let process_id = manager.allocate_process_id().await; + emit_unified_exec_tty_metric(&turn.session_telemetry, tty); + return execute_unified_exec( + manager, + ExecCommandRequest { + command, + shell_type, + hook_command, + process_id, + environment_id: turn_environment.environment_id.clone(), + yield_time_ms, + max_output_tokens, + cwd, + cwd_uri, + sandbox_cwd, + environment, + shell_mode, + network: context.turn.network.clone(), + tty, + sandbox_permissions, + additional_permissions: None, + additional_permissions_preapproved: false, + justification, + prefix_rule, + }, + &context, + &command_for_display, + ) + .await; + } + let exec_permission_approvals_enabled = session.features().enabled(Feature::ExecPermissionApprovals); let requested_additional_permissions = additional_permissions.clone(); @@ -223,7 +281,6 @@ impl ExecCommandHandler { ) { let approval_policy = context.turn.approval_policy.value(); - manager.release_process_id(process_id).await; return Err(FunctionCallError::RespondToModel(format!( "approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}" ))); @@ -248,12 +305,10 @@ impl ExecCommandHandler { |permissions| Ok(Some(permissions)), ) { Ok(normalized) => normalized, - Err(err) => { - manager.release_process_id(process_id).await; - return Err(FunctionCallError::RespondToModel(err)); - } + Err(err) => return Err(FunctionCallError::RespondToModel(err)), }; + let fs = environment.get_filesystem(); if let Some(output) = intercept_apply_patch( &command, &cwd, @@ -267,7 +322,6 @@ impl ExecCommandHandler { ) .await? { - manager.release_process_id(process_id).await; return Ok(boxed_tool_output(ExecCommandToolOutput { event_call_id: String::new(), chunk_id: String::new(), @@ -282,56 +336,70 @@ impl ExecCommandHandler { })); } + let process_id = manager.allocate_process_id().await; emit_unified_exec_tty_metric(&turn.session_telemetry, tty); - match manager - .exec_command( - ExecCommandRequest { - command, - shell_type, - hook_command: hook_command.clone(), - process_id, - yield_time_ms, - max_output_tokens, - cwd, - sandbox_cwd: base_cwd, - environment, - shell_mode, - network: context.turn.network.clone(), - tty, - sandbox_permissions: effective_additional_permissions.sandbox_permissions, - additional_permissions: normalized_additional_permissions, - additional_permissions_preapproved: effective_additional_permissions - .permissions_preapproved, - justification, - prefix_rule, - }, - &context, - ) - .await - { - Ok(response) => Ok(boxed_tool_output(response)), - Err(UnifiedExecError::SandboxDenied { output, .. }) => { - let output_text = output.aggregated_output.text; - let original_token_count = approx_token_count(&output_text); - Ok(boxed_tool_output(ExecCommandToolOutput { - event_call_id: context.call_id.clone(), - chunk_id: generate_chunk_id(), - wall_time: output.duration, - raw_output: output_text.into_bytes(), - truncation_policy: turn.truncation_policy, - max_output_tokens, - // Sandbox denial is terminal, so there is no live - // process for write_stdin to resume. - process_id: None, - exit_code: Some(output.exit_code), - original_token_count: Some(original_token_count), - hook_command: Some(hook_command), - })) - } - Err(err) => Err(FunctionCallError::RespondToModel(format!( - "exec_command failed for `{command_for_display}`: {err:?}" - ))), + execute_unified_exec( + manager, + ExecCommandRequest { + command, + shell_type, + hook_command, + process_id, + environment_id: turn_environment.environment_id.clone(), + yield_time_ms, + max_output_tokens, + cwd, + cwd_uri, + sandbox_cwd, + environment, + shell_mode, + network: context.turn.network.clone(), + tty, + sandbox_permissions: effective_additional_permissions.sandbox_permissions, + additional_permissions: normalized_additional_permissions, + additional_permissions_preapproved: effective_additional_permissions + .permissions_preapproved, + justification, + prefix_rule, + }, + &context, + &command_for_display, + ) + .await + } +} + +async fn execute_unified_exec( + manager: &UnifiedExecProcessManager, + request: ExecCommandRequest, + context: &UnifiedExecContext, + command_for_display: &str, +) -> Result, FunctionCallError> { + let max_output_tokens = request.max_output_tokens; + let hook_command = request.hook_command.clone(); + match manager.exec_command(request, context).await { + Ok(response) => Ok(boxed_tool_output(response)), + Err(UnifiedExecError::SandboxDenied { output, .. }) => { + let output_text = output.aggregated_output.text; + let original_token_count = approx_token_count(&output_text); + Ok(boxed_tool_output(ExecCommandToolOutput { + event_call_id: context.call_id.clone(), + chunk_id: generate_chunk_id(), + wall_time: output.duration, + raw_output: output_text.into_bytes(), + truncation_policy: context.turn.truncation_policy, + max_output_tokens, + // Sandbox denial is terminal, so there is no live + // process for write_stdin to resume. + process_id: None, + exit_code: Some(output.exit_code), + original_token_count: Some(original_token_count), + hook_command: Some(hook_command), + })) } + Err(err) => Err(FunctionCallError::RespondToModel(format!( + "exec_command failed for `{command_for_display}`: {err:?}" + ))), } } diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 7e97e4ad51..78e8f01437 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -19,6 +19,7 @@ use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::begin_network_approval; use crate::tools::network_approval::finish_deferred_network_approval; use crate::tools::network_approval::finish_immediate_network_approval; +use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; @@ -51,6 +52,13 @@ pub(crate) struct OrchestratorRunResult { pub deferred_network_approval: Option, } +struct InitialApproval { + requirement: ExecApprovalRequirement, + already_approved: bool, + strict_auto_review: bool, + use_guardian: bool, +} + impl ToolOrchestrator { pub fn new() -> Self { Self { @@ -129,28 +137,23 @@ impl ToolOrchestrator { } } - pub async fn run( - &mut self, + async fn resolve_initial_approval( tool: &mut T, req: &Rq, tool_ctx: &ToolCtx, turn_ctx: &crate::session::turn_context::TurnContext, approval_policy: AskForApproval, - ) -> Result, ToolError> + ) -> Result where - T: ToolRuntime, + T: Approvable, { let otel = turn_ctx.session_telemetry.clone(); let otel_tn = flat_tool_name(&tool_ctx.tool_name).into_owned(); let otel_ci = &tool_ctx.call_id; let strict_auto_review = tool_ctx.session.strict_auto_review_enabled_for_turn().await; let use_guardian = routes_approval_to_guardian(turn_ctx) || strict_auto_review; - - // 1) Approval let mut already_approved = false; - let file_system_sandbox_policy = turn_ctx.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) }); @@ -218,6 +221,57 @@ impl ToolOrchestrator { } } + Ok(InitialApproval { + requirement, + already_approved, + strict_auto_review, + use_guardian, + }) + } + + /// Runs the normal command-approval phase without constructing a sandbox attempt. + /// + /// Callers must validate that enforcement is disabled or owned by the target + /// environment before using this path. + pub(crate) async fn approve_direct_execution( + &mut self, + tool: &mut T, + req: &Rq, + tool_ctx: &ToolCtx, + turn_ctx: &crate::session::turn_context::TurnContext, + approval_policy: AskForApproval, + ) -> Result<(), ToolError> + where + T: Approvable, + { + Self::resolve_initial_approval(tool, req, tool_ctx, turn_ctx, approval_policy) + .await + .map(|_| ()) + } + + pub async fn run( + &mut self, + tool: &mut T, + req: &Rq, + tool_ctx: &ToolCtx, + turn_ctx: &crate::session::turn_context::TurnContext, + approval_policy: AskForApproval, + ) -> Result, ToolError> + where + T: ToolRuntime, + { + let otel = turn_ctx.session_telemetry.clone(); + let InitialApproval { + requirement, + already_approved, + strict_auto_review, + use_guardian, + } = Self::resolve_initial_approval(tool, req, tool_ctx, turn_ctx, approval_policy).await?; + let otel_tn = flat_tool_name(&tool_ctx.tool_name).into_owned(); + let otel_ci = &tool_ctx.call_id; + let file_system_sandbox_policy = turn_ctx.file_system_sandbox_policy(); + let network_sandbox_policy = turn_ctx.network_sandbox_policy(); + // 2) First attempt under the selected sandbox. let sandbox_override = sandbox_override_for_first_attempt( tool.sandbox_permissions(req), @@ -484,7 +538,7 @@ impl ToolOrchestrator { // PermissionRequest hooks take top precedence for answering approval // prompts. If no matching hook returns a decision, fall back to the // normal guardian or user approval path. - async fn request_approval( + async fn request_approval( tool: &mut T, req: &Rq, permission_request_run_id: &str, @@ -494,7 +548,7 @@ impl ToolOrchestrator { otel: &codex_otel::SessionTelemetry, ) -> Result where - T: ToolRuntime, + T: Approvable, { if evaluate_permission_request_hooks && let Some(permission_request) = tool.permission_request_payload(req) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 9aff1ac795..24fe2fb87c 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -51,6 +51,7 @@ use codex_sandboxing::SandboxablePreference; use codex_shell_command::powershell::prefix_powershell_script_with_utf8; use codex_tools::UnifiedExecShellMode; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use futures::future::BoxFuture; use std::collections::HashMap; use std::sync::Arc; @@ -64,8 +65,10 @@ pub struct UnifiedExecRequest { pub shell_type: ShellType, pub hook_command: String, pub process_id: i32, + pub environment_id: String, pub cwd: AbsolutePathBuf, - pub sandbox_cwd: AbsolutePathBuf, + pub cwd_uri: PathUri, + pub sandbox_cwd: Option, pub environment: Arc, pub env: HashMap, pub exec_server_env_config: Option, @@ -85,7 +88,8 @@ pub struct UnifiedExecRequest { #[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)] pub struct UnifiedExecApprovalKey { pub command: Vec, - pub cwd: AbsolutePathBuf, + pub environment_id: String, + pub cwd: PathUri, pub tty: bool, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, @@ -137,7 +141,8 @@ impl Approvable for UnifiedExecRuntime<'_> { fn approval_keys(&self, req: &UnifiedExecRequest) -> Vec { vec![UnifiedExecApprovalKey { command: canonicalize_command_for_approval(&req.command), - cwd: req.cwd.clone(), + environment_id: req.environment_id.clone(), + cwd: req.cwd_uri.clone(), tty: req.tty, sandbox_permissions: req.sandbox_permissions, additional_permissions: req.additional_permissions.clone(), @@ -224,7 +229,7 @@ impl Approvable for UnifiedExecRuntime<'_> { impl<'a> ToolRuntime for UnifiedExecRuntime<'a> { fn sandbox_cwd<'b>(&self, req: &'b UnifiedExecRequest) -> Option<&'b AbsolutePathBuf> { - Some(&req.sandbox_cwd) + req.sandbox_cwd.as_ref() } fn network_approval_spec( @@ -461,8 +466,10 @@ mod tests { shell_type: ShellType::Sh, hook_command: "pwd".to_string(), process_id: 1000, - cwd, - sandbox_cwd: sandbox_cwd.clone(), + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + cwd: cwd.clone(), + cwd_uri: PathUri::from_abs_path(&cwd), + sandbox_cwd: Some(sandbox_cwd.clone()), environment: Arc::new(Environment::default_for_tests()), env: HashMap::new(), exec_server_env_config: None, @@ -483,6 +490,33 @@ mod tests { assert_eq!(runtime.sandbox_cwd(&request), Some(&sandbox_cwd)); } + #[tokio::test] + async fn unified_exec_approval_keys_include_environment_and_canonical_cwd() { + let manager = UnifiedExecProcessManager::default(); + let runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct); + let mut request = test_request( + SandboxPermissions::UseDefault, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + }, + ); + request.environment_id = "windows".to_string(); + request.cwd_uri = PathUri::parse("file:///C:/workspace").expect("Windows cwd URI"); + + assert_eq!( + runtime.approval_keys(&request), + vec![UnifiedExecApprovalKey { + command: canonicalize_command_for_approval(&request.command), + environment_id: "windows".to_string(), + cwd: request.cwd_uri, + tty: request.tty, + sandbox_permissions: request.sandbox_permissions, + additional_permissions: request.additional_permissions, + }] + ); + } + #[tokio::test] async fn zsh_fork_first_attempt_preserves_parent_sandbox_override() { let manager = UnifiedExecProcessManager::default(); @@ -560,8 +594,10 @@ mod tests { shell_type: ShellType::Zsh, hook_command: "echo hi".to_string(), process_id: 1000, + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd.clone(), - sandbox_cwd: cwd, + cwd_uri: PathUri::from_abs_path(&cwd), + sandbox_cwd: Some(cwd), environment: Arc::new(Environment::default_for_tests()), env: HashMap::new(), exec_server_env_config: None, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 65b18b064d..bde3438534 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -33,6 +33,7 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_tools::UnifiedExecShellMode; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_path_uri::PathUri; use rand::Rng; use rand::rng; use tokio::sync::Mutex; @@ -93,10 +94,13 @@ pub(crate) struct ExecCommandRequest { pub shell_type: ShellType, pub hook_command: String, pub process_id: i32, + pub environment_id: String, pub yield_time_ms: u64, pub max_output_tokens: Option, + /// App-host-compatible cwd retained for approval and event compatibility. pub cwd: AbsolutePathBuf, - pub sandbox_cwd: AbsolutePathBuf, + pub cwd_uri: PathUri, + pub sandbox_cwd: Option, 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 8c1b2f745f..7b15745bba 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -53,6 +53,7 @@ use crate::unified_exec::process::UnifiedExecProcess; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ExecCommandSource; use codex_tools::ToolName; use codex_utils_absolute_path::AbsolutePathBuf; @@ -74,6 +75,9 @@ const NETWORK_ACCESS_DENIED_MESSAGE: &str = "Network access was denied by the Codex sandbox network proxy."; const LATE_NETWORK_DENIAL_GRACE_PERIOD: Duration = Duration::from_millis(100); const INTERRUPT: &str = "\u{3}"; +const MANAGED_REMOTE_EXEC_UNSUPPORTED: &str = "remote exec with a managed permission profile is not supported until sandbox construction runs on the exec-server; select an external or disabled permission profile"; +const MANAGED_REMOTE_NETWORK_UNSUPPORTED: &str = "remote exec with managed network enforcement is not supported until network policy runs on the exec-server"; +const REMOTE_PERMISSION_ELEVATION_UNSUPPORTED: &str = "remote exec does not support per-command permission elevation until it can be enforced by the exec-server"; /// Test-only override for deterministic unified exec process IDs. /// @@ -131,19 +135,20 @@ fn env_overlay_for_exec_server( .collect() } -fn exec_server_env_for_request( - request: &ExecRequest, +fn exec_server_env( + exec_server_env_config: Option<&ExecServerEnvConfig>, + request_env: &HashMap, ) -> ( Option, HashMap, ) { - if let Some(exec_server_env_config) = &request.exec_server_env_config { + if let Some(exec_server_env_config) = exec_server_env_config { ( Some(exec_server_env_config.policy.clone()), - env_overlay_for_exec_server(&request.env, &exec_server_env_config.local_policy_env), + env_overlay_for_exec_server(request_env, &exec_server_env_config.local_policy_env), ) } else { - (None, request.env.clone()) + (None, request_env.clone()) } } @@ -152,7 +157,7 @@ fn exec_server_params_for_request( request: &ExecRequest, tty: bool, ) -> codex_exec_server::ExecParams { - let (env_policy, env) = exec_server_env_for_request(request); + let (env_policy, env) = exec_server_env(request.exec_server_env_config.as_ref(), &request.env); codex_exec_server::ExecParams { process_id: exec_server_process_id(process_id).into(), argv: request.command.clone(), @@ -165,6 +170,45 @@ fn exec_server_params_for_request( } } +fn exec_server_params_for_remote_request( + request: &UnifiedExecToolRequest, +) -> codex_exec_server::ExecParams { + let (env_policy, env) = exec_server_env(request.exec_server_env_config.as_ref(), &request.env); + codex_exec_server::ExecParams { + process_id: exec_server_process_id(request.process_id).into(), + argv: if matches!(request.shell_type, crate::shell::ShellType::PowerShell) { + codex_shell_command::powershell::prefix_powershell_script_with_utf8(&request.command) + } else { + request.command.clone() + }, + cwd: request.cwd_uri.clone(), + env_policy, + env, + tty: request.tty, + pipe_stdin: false, + // App-host sandbox helpers are never valid on the remote executor. + arg0: None, + } +} + +fn validate_remote_direct_request( + permission_profile: &PermissionProfile, + managed_network: bool, + sandbox_permissions: crate::sandboxing::SandboxPermissions, + additional_permissions: Option<&codex_protocol::models::AdditionalPermissionProfile>, +) -> Result<(), &'static str> { + if matches!(permission_profile, PermissionProfile::Managed { .. }) { + return Err(MANAGED_REMOTE_EXEC_UNSUPPORTED); + } + if managed_network { + return Err(MANAGED_REMOTE_NETWORK_UNSUPPORTED); + } + if sandbox_permissions.requests_sandbox_override() || additional_permissions.is_some() { + return Err(REMOTE_PERMISSION_ELEVATION_UNSUPPORTED); + } + Ok(()) +} + /// Borrowed process state prepared for a `write_stdin` or poll operation. struct PreparedProcessHandles { process: Arc, @@ -1027,6 +1071,13 @@ impl UnifiedExecProcessManager { cwd: AbsolutePathBuf, context: &UnifiedExecContext, ) -> Result<(UnifiedExecProcess, Option), UnifiedExecError> { + if request.environment.is_remote() { + let process = self + .open_remote_session_with_approval(request, context) + .await?; + return Ok((process, None)); + } + let local_policy_env = create_env( &context.turn.shell_environment_policy, /*thread_id*/ None, @@ -1065,7 +1116,9 @@ impl UnifiedExecProcessManager { shell_type: request.shell_type, hook_command: request.hook_command.clone(), process_id: request.process_id, + environment_id: request.environment_id.clone(), cwd, + cwd_uri: request.cwd_uri.clone(), sandbox_cwd: request.sandbox_cwd.clone(), environment: Arc::clone(&request.environment), env, @@ -1111,6 +1164,94 @@ impl UnifiedExecProcessManager { }) } + async fn open_remote_session_with_approval( + &self, + request: &ExecCommandRequest, + context: &UnifiedExecContext, + ) -> Result { + validate_remote_direct_request( + &context.turn.permission_profile(), + request.network.is_some(), + request.sandbox_permissions, + request.additional_permissions.as_ref(), + ) + .map_err(|message| UnifiedExecError::create_process(message.to_string()))?; + + let exec_approval_requirement = context + .session + .services + .exec_policy + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &request.command, + approval_policy: context.turn.approval_policy.value(), + permission_profile: context.turn.permission_profile(), + windows_sandbox_level: context.turn.windows_sandbox_level, + sandbox_permissions: request.sandbox_permissions, + prefix_rule: request.prefix_rule.clone(), + }) + .await; + let mut env = HashMap::from([( + CODEX_THREAD_ID_ENV_VAR.to_string(), + context.session.thread_id.to_string(), + )]); + env = apply_unified_exec_env(env); + let req = UnifiedExecToolRequest { + command: request.command.clone(), + shell_type: request.shell_type, + hook_command: request.hook_command.clone(), + process_id: request.process_id, + environment_id: request.environment_id.clone(), + cwd: request.cwd.clone(), + cwd_uri: request.cwd_uri.clone(), + sandbox_cwd: None, + environment: Arc::clone(&request.environment), + env, + exec_server_env_config: Some(ExecServerEnvConfig { + policy: exec_env_policy_from_shell_policy(&context.turn.shell_environment_policy), + local_policy_env: HashMap::new(), + }), + explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(), + network: None, + tty: request.tty, + sandbox_permissions: request.sandbox_permissions, + additional_permissions: None, + #[cfg(unix)] + additional_permissions_preapproved: false, + justification: request.justification.clone(), + exec_approval_requirement, + }; + let mut orchestrator = ToolOrchestrator::new(); + let mut runtime = UnifiedExecRuntime::new(self, request.shell_mode.clone()); + let tool_ctx = ToolCtx { + session: context.session.clone(), + turn: context.turn.clone(), + call_id: context.call_id.clone(), + tool_name: ToolName::plain("exec_command"), + }; + orchestrator + .approve_direct_execution( + &mut runtime, + &req, + &tool_ctx, + &context.turn, + context.turn.approval_policy.value(), + ) + .await + .map_err(|error| match error { + ToolError::Rejected(message) => UnifiedExecError::create_process(message), + ToolError::Codex(error) => UnifiedExecError::create_process(error.to_string()), + })?; + + let started = request + .environment + .get_exec_backend() + .start(exec_server_params_for_remote_request(&req)) + .await + .map_err(|error| UnifiedExecError::create_process(error.to_string()))?; + UnifiedExecProcess::from_exec_server_started(started, codex_sandboxing::SandboxType::None) + .await + } + pub(super) async fn collect_output_until_deadline( output_buffer: &OutputBuffer, output_notify: &Arc, 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 266a13896d..196f4c80ed 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -129,11 +129,127 @@ fn exec_server_params_use_env_policy_overlay_contract() { ); } +#[tokio::test] +async fn remote_exec_params_preserve_target_cwd_and_clear_host_arg0() { + let compatibility_cwd: codex_utils_absolute_path::AbsolutePathBuf = std::env::current_dir() + .expect("current dir") + .try_into() + .expect("absolute path"); + let cwd_uri = codex_utils_path_uri::PathUri::parse("file:///C:/workspace/build") + .expect("Windows cwd URI"); + let policy = codex_exec_server::ExecEnvPolicy { + inherit: codex_protocol::config_types::ShellEnvironmentPolicyInherit::Core, + ignore_default_excludes: false, + exclude: vec!["SECRET".to_string()], + r#set: HashMap::new(), + include_only: Vec::new(), + }; + let request = UnifiedExecToolRequest { + command: vec![ + r"C:\Program Files\PowerShell\7\pwsh.exe".to_string(), + "-Command".to_string(), + "Get-Location".to_string(), + ], + shell_type: crate::shell::ShellType::PowerShell, + hook_command: "Get-Location".to_string(), + process_id: 4321, + environment_id: "windows".to_string(), + cwd: compatibility_cwd, + cwd_uri: cwd_uri.clone(), + sandbox_cwd: None, + environment: Arc::new(codex_exec_server::Environment::default_for_tests()), + env: HashMap::from([("CODEX_CI".to_string(), "1".to_string())]), + exec_server_env_config: Some(ExecServerEnvConfig { + policy: policy.clone(), + local_policy_env: HashMap::new(), + }), + explicit_env_overrides: HashMap::new(), + network: None, + tty: false, + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + #[cfg(unix)] + additional_permissions_preapproved: false, + justification: None, + exec_approval_requirement: crate::tools::sandboxing::ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + }; + let expected_argv = + codex_shell_command::powershell::prefix_powershell_script_with_utf8(&request.command); + + assert_eq!( + exec_server_params_for_remote_request(&request), + codex_exec_server::ExecParams { + process_id: codex_exec_server::ProcessId::from("4321"), + argv: expected_argv, + cwd: cwd_uri, + env_policy: Some(policy), + env: request.env, + tty: false, + pipe_stdin: false, + arg0: None, + } + ); +} + #[test] fn exec_server_process_id_matches_unified_exec_process_id() { assert_eq!(exec_server_process_id(/*process_id*/ 4321), "4321"); } +#[test] +fn remote_direct_execution_requires_target_owned_or_disabled_enforcement() { + use codex_protocol::models::AdditionalPermissionProfile; + use codex_protocol::models::PermissionProfile; + use codex_protocol::permissions::NetworkSandboxPolicy; + + assert_eq!( + [ + validate_remote_direct_request( + &PermissionProfile::Disabled, + /*managed_network*/ false, + crate::sandboxing::SandboxPermissions::UseDefault, + /*additional_permissions*/ None, + ), + validate_remote_direct_request( + &PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }, + /*managed_network*/ false, + crate::sandboxing::SandboxPermissions::UseDefault, + /*additional_permissions*/ None, + ), + validate_remote_direct_request( + &PermissionProfile::read_only(), + /*managed_network*/ false, + crate::sandboxing::SandboxPermissions::UseDefault, + /*additional_permissions*/ None, + ), + validate_remote_direct_request( + &PermissionProfile::Disabled, + /*managed_network*/ true, + crate::sandboxing::SandboxPermissions::UseDefault, + /*additional_permissions*/ None, + ), + validate_remote_direct_request( + &PermissionProfile::Disabled, + /*managed_network*/ false, + crate::sandboxing::SandboxPermissions::RequireEscalated, + Some(&AdditionalPermissionProfile::default()), + ), + ], + [ + Ok(()), + Ok(()), + Err(MANAGED_REMOTE_EXEC_UNSUPPORTED), + Err(MANAGED_REMOTE_NETWORK_UNSUPPORTED), + Err(REMOTE_PERMISSION_ELEVATION_UNSUPPORTED), + ] + ); +} + #[tokio::test] async fn network_denial_fallback_message_names_sandbox_network_proxy() { let message = network_denial_message_for_session(/*session*/ None, /*deferred*/ None).await; @@ -164,6 +280,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { Arc::clone(&turn), "call-unified-denied".to_string(), ); + let turn_environment = turn.environments.primary().expect("primary environment"); let request = ExecCommandRequest { command: vec![ "sh".to_string(), @@ -173,12 +290,14 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { shell_type: crate::shell::ShellType::Sh, hook_command: "echo before".to_string(), process_id: 123, + environment_id: turn_environment.environment_id.clone(), yield_time_ms: 1000, max_output_tokens: None, #[allow(deprecated)] cwd: turn.cwd.clone(), + cwd_uri: turn_environment.cwd_uri().clone(), #[allow(deprecated)] - sandbox_cwd: turn.cwd.clone(), + sandbox_cwd: Some(turn.cwd.clone()), environment: turn .environments .primary_environment() diff --git a/codex-rs/shell-command/src/powershell.rs b/codex-rs/shell-command/src/powershell.rs index 9730439bea..fe7ecf1584 100644 --- a/codex-rs/shell-command/src/powershell.rs +++ b/codex-rs/shell-command/src/powershell.rs @@ -225,6 +225,27 @@ mod tests { ); } + #[test] + fn prefixes_windows_powershell_path_on_a_foreign_host() { + let shell = r"C:\Program Files\PowerShell\7\pwsh.exe"; + let cmd = vec![ + shell.to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + "Write-Host hi".to_string(), + ]; + + assert_eq!( + prefix_powershell_script_with_utf8(&cmd), + vec![ + shell.to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + format!("{UTF8_OUTPUT_PREFIX}Write-Host hi"), + ] + ); + } + #[test] fn does_not_duplicate_utf8_prefix() { let cmd = vec![ diff --git a/codex-rs/shell-command/src/shell_detect.rs b/codex-rs/shell-command/src/shell_detect.rs index 69a66f0563..f115f292b4 100644 --- a/codex-rs/shell-command/src/shell_detect.rs +++ b/codex-rs/shell-command/src/shell_detect.rs @@ -38,6 +38,12 @@ impl DetectedShell { pub fn detect_shell_type(shell_path: impl AsRef) -> Option { let shell_path = shell_path.as_ref(); + if let Some(path) = shell_path.as_os_str().to_str() + && let Some(file_name) = path.rsplit(['/', '\\']).next() + && file_name != path + { + return detect_shell_type(PathBuf::from(file_name)); + } match shell_path.as_os_str().to_str() { Some("zsh") => Some(ShellType::Zsh), Some("sh") => Some(ShellType::Sh), @@ -347,6 +353,12 @@ mod tests { detect_shell_type(PathBuf::from("pwsh.exe")), Some(ShellType::PowerShell) ); + assert_eq!( + detect_shell_type(PathBuf::from( + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" + )), + Some(ShellType::PowerShell) + ); assert_eq!( detect_shell_type(PathBuf::from("/usr/local/bin/pwsh")), Some(ShellType::PowerShell)