Codex Handbook
core/src/tools/handlers/unified_exec/exec_command.rs 384 lines
use std::sync::Arc;use crate::function_tool::FunctionCallError;use crate::maybe_emit_implicit_skill_invocation;use crate::tools::context::ExecCommandToolOutput;use crate::tools::context::ToolInvocation;use crate::tools::context::ToolPayload;use crate::tools::context::boxed_tool_output;use crate::tools::handlers::apply_granted_turn_permissions;use crate::tools::handlers::apply_patch::intercept_apply_patch;use crate::tools::handlers::implicit_granted_permissions;use crate::tools::handlers::normalize_and_validate_additional_permissions;use crate::tools::handlers::parse_arguments;use crate::tools::handlers::parse_arguments_with_base_path;use crate::tools::handlers::resolve_tool_environment;use crate::tools::handlers::rewrite_function_string_argument;use crate::tools::handlers::updated_hook_command;use crate::tools::hook_names::HookToolName;use crate::tools::registry::CoreToolRuntime;use crate::tools::registry::PostToolUsePayload;use crate::tools::registry::PreToolUsePayload;use crate::tools::registry::ToolExecutor;use crate::unified_exec::ExecCommandRequest;use crate::unified_exec::UnifiedExecContext;use crate::unified_exec::UnifiedExecError;use crate::unified_exec::UnifiedExecProcessManager;use crate::unified_exec::generate_chunk_id;use codex_features::Feature;use codex_otel::SessionTelemetry;use codex_otel::TOOL_CALL_UNIFIED_EXEC_METRIC;use codex_tools::ToolName;use codex_tools::ToolSpec;use codex_utils_output_truncation::approx_token_count;use super::super::shell_spec::CommandToolOptions;use super::super::shell_spec::create_exec_command_tool_with_environment_id;use super::ExecCommandArgs;use super::ExecCommandEnvironmentArgs;use super::get_command;use super::post_unified_exec_tool_use_payload;use super::shell_mode_for_environment;#[derive(Clone, Copy)]pub(crate) struct ExecCommandHandlerOptions {    pub(crate) allow_login_shell: bool,    pub(crate) exec_permission_approvals_enabled: bool,    pub(crate) include_environment_id: bool,    pub(crate) include_shell_parameter: bool,}pub struct ExecCommandHandler {    options: ExecCommandHandlerOptions,}impl Default for ExecCommandHandler {    fn default() -> Self {        Self {            options: ExecCommandHandlerOptions {                allow_login_shell: false,                exec_permission_approvals_enabled: false,                include_environment_id: false,                include_shell_parameter: true,            },        }    }}impl ExecCommandHandler {    pub(crate) fn new(options: ExecCommandHandlerOptions) -> Self {        Self { options }    }}impl ToolExecutor<ToolInvocation> for ExecCommandHandler {    fn tool_name(&self) -> ToolName {        ToolName::plain("exec_command")    }    fn spec(&self) -> ToolSpec {        create_exec_command_tool_with_environment_id(            CommandToolOptions {                allow_login_shell: self.options.allow_login_shell,                exec_permission_approvals_enabled: self.options.exec_permission_approvals_enabled,            },            self.options.include_environment_id,            self.options.include_shell_parameter,        )    }    fn supports_parallel_tool_calls(&self) -> bool {        true    }    fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {        Box::pin(self.handle_call(invocation))    }}impl ExecCommandHandler {    async fn handle_call(        &self,        invocation: ToolInvocation,    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {        let ToolInvocation {            session,            turn,            tracker,            call_id,            payload,            ..        } = invocation;        let arguments = match payload {            ToolPayload::Function { arguments } => arguments,            _ => {                return Err(FunctionCallError::RespondToModel(                    "exec_command handler received unsupported payload".to_string(),                ));            }        };        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 Some(turn_environment) =            resolve_tool_environment(turn.as_ref(), environment_args.environment_id.as_deref())?        else {            return Err(FunctionCallError::RespondToModel(                "unified exec is unavailable in this session".to_string(),            ));        };        // TODO(anp): Resolve tool paths using the selected environment's native path convention        // so unified exec can support relative paths in foreign environments.        let native_environment_cwd = turn_environment.cwd().to_abs_path().map_err(|err| {            FunctionCallError::RespondToModel(format!(                "environment cwd `{}` is not native to the Codex host: {err}",                turn_environment.cwd()            ))        })?;        let cwd = environment_args            .workdir            .as_deref()            .filter(|workdir| !workdir.is_empty())            .map_or_else(                || native_environment_cwd.clone(),                |workdir| native_environment_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)?;        let hook_command = args.cmd.clone();        maybe_emit_implicit_skill_invocation(            session.as_ref(),            context.turn.as_ref(),            &hook_command,            &cwd,        )        .await;        let process_id = manager.allocate_process_id().await;        let shell_mode =            shell_mode_for_environment(&turn.unified_exec_shell_mode, environment.as_ref());        // Remote environments may use a different OS and must build commands with their native        // shell; fall back to the session shell when the environment did not report one.        let shell = turn_environment            .shell            .clone()            .map(Arc::new)            .unwrap_or_else(|| session.user_shell());        let resolved_command = get_command(            &args,            shell,            &shell_mode,            turn.config.permissions.allow_login_shell,        )        .map_err(FunctionCallError::RespondToModel)?;        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 ExecCommandArgs {            tty,            yield_time_ms,            max_output_tokens,            sandbox_permissions,            additional_permissions,            justification,            prefix_rule,            ..        } = args;        let exec_permission_approvals_enabled =            session.features().enabled(Feature::ExecPermissionApprovals);        let requested_additional_permissions = additional_permissions.clone();        let effective_additional_permissions = apply_granted_turn_permissions(            context.session.as_ref(),            &turn_environment.environment_id,            cwd.as_path(),            sandbox_permissions,            additional_permissions,        )        .await;        let additional_permissions_allowed = exec_permission_approvals_enabled            || (session.features().enabled(Feature::RequestPermissionsTool)                && effective_additional_permissions.permissions_preapproved);        // Sticky turn permissions have already been approved, so they should        // continue through the normal exec approval flow for the command.        if effective_additional_permissions            .sandbox_permissions            .requests_sandbox_override()            && !effective_additional_permissions.permissions_preapproved            && !matches!(                context.turn.approval_policy.value(),                codex_protocol::protocol::AskForApproval::OnRequest            )        {            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:?}"            )));        }        let normalized_additional_permissions = match implicit_granted_permissions(            sandbox_permissions,            requested_additional_permissions.as_ref(),            &effective_additional_permissions,        )        .map_or_else(            || {                normalize_and_validate_additional_permissions(                    additional_permissions_allowed,                    context.turn.approval_policy.value(),                    effective_additional_permissions.sandbox_permissions,                    effective_additional_permissions.additional_permissions,                    effective_additional_permissions.permissions_preapproved,                    &cwd,                )            },            |permissions| Ok(Some(permissions)),        ) {            Ok(normalized) => normalized,            Err(err) => {                manager.release_process_id(process_id).await;                return Err(FunctionCallError::RespondToModel(err));            }        };        if let Some(output) = intercept_apply_patch(            &command,            &cwd,            fs.as_ref(),            turn_environment.clone(),            context.session.clone(),            context.turn.clone(),            Some(&tracker),            &context.call_id,            "exec_command",        )        .await?        {            manager.release_process_id(process_id).await;            return Ok(boxed_tool_output(ExecCommandToolOutput {                event_call_id: String::new(),                chunk_id: String::new(),                wall_time: std::time::Duration::ZERO,                raw_output: output.into_text().into_bytes(),                truncation_policy: turn.truncation_policy,                max_output_tokens,                process_id: None,                exit_code: None,                original_token_count: None,                hook_command: None,            }));        }        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: native_environment_cwd,                    turn_environment: turn_environment.clone(),                    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:?}"            ))),        }    }}impl CoreToolRuntime for ExecCommandHandler {    fn matches_kind(&self, payload: &ToolPayload) -> bool {        matches!(payload, ToolPayload::Function { .. })    }    fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {        let ToolPayload::Function { arguments } = &invocation.payload else {            return None;        };        parse_arguments::<ExecCommandArgs>(arguments)            .ok()            .map(|args| PreToolUsePayload {                tool_name: HookToolName::bash(),                tool_input: serde_json::json!({ "command": args.cmd }),            })    }    fn with_updated_hook_input(        &self,        mut invocation: ToolInvocation,        updated_input: serde_json::Value,    ) -> Result<ToolInvocation, FunctionCallError> {        let ToolPayload::Function { arguments } = invocation.payload else {            return Err(FunctionCallError::RespondToModel(                "hook input rewrite received unsupported exec_command payload".to_string(),            ));        };        invocation.payload = ToolPayload::Function {            arguments: rewrite_function_string_argument(                &arguments,                "exec_command",                "cmd",                updated_hook_command(&updated_input)?,            )?,        };        Ok(invocation)    }    fn post_tool_use_payload(        &self,        invocation: &ToolInvocation,        result: &dyn crate::tools::context::ToolOutput,    ) -> Option<PostToolUsePayload> {        post_unified_exec_tool_use_payload(invocation, result)    }}fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) {    session_telemetry.counter(        TOOL_CALL_UNIFIED_EXEC_METRIC,        /*inc*/ 1,        &[("tty", if tty { "true" } else { "false" })],    );}