diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4eb2c3e5f..eba09e46d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2627,11 +2627,15 @@ version = "0.0.0" dependencies = [ "codex-app-server-protocol", "codex-code-mode", + "codex-features", "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-pty", "pretty_assertions", "rmcp", "serde", "serde_json", + "tracing", ] [[package]] diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7137bf4d3..f1f008530 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -944,7 +944,9 @@ impl TurnContext { .with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone()) .with_web_search_config(self.tools_config.web_search_config.clone()) .with_allow_login_shell(self.tools_config.allow_login_shell) - .with_agent_roles(config.agent_roles.clone()); + .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( + &config.agent_roles, + )); Self { sub_id: self.sub_id.clone(), @@ -1396,13 +1398,15 @@ impl Session { windows_sandbox_level: session_configuration.windows_sandbox_level, }) .with_unified_exec_shell_mode_for_session( - user_shell, + crate::tools::spec::tool_user_shell_type(user_shell), shell_zsh_path, main_execve_wrapper_exe, ) .with_web_search_config(per_turn_config.web_search_config.clone()) .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) - .with_agent_roles(per_turn_config.agent_roles.clone()); + .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( + &per_turn_config.agent_roles, + )); let cwd = session_configuration.cwd.clone(); @@ -5468,13 +5472,15 @@ async fn spawn_review_thread( windows_sandbox_level: parent_turn_context.windows_sandbox_level, }) .with_unified_exec_shell_mode_for_session( - sess.services.user_shell.as_ref(), + crate::tools::spec::tool_user_shell_type(sess.services.user_shell.as_ref()), sess.services.shell_zsh_path.as_ref(), sess.services.main_execve_wrapper_exe.as_ref(), ) .with_web_search_config(/*web_search_config*/ None) .with_allow_login_shell(config.permissions.allow_login_shell) - .with_agent_roles(config.agent_roles.clone()); + .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( + &config.agent_roles, + )); let review_prompt = resolved.prompt.clone(); let provider = parent_turn_context.provider.clone(); diff --git a/codex-rs/core/src/original_image_detail.rs b/codex-rs/core/src/original_image_detail.rs index 8db219f12..c3e5c3d9a 100644 --- a/codex-rs/core/src/original_image_detail.rs +++ b/codex-rs/core/src/original_image_detail.rs @@ -1,28 +1,2 @@ -use codex_features::Feature; -use codex_features::Features; -use codex_protocol::models::ImageDetail; -use codex_protocol::openai_models::ModelInfo; - -pub(crate) fn can_request_original_image_detail( - features: &Features, - model_info: &ModelInfo, -) -> bool { - model_info.supports_image_detail_original && features.enabled(Feature::ImageDetailOriginal) -} - -pub(crate) fn normalize_output_image_detail( - features: &Features, - model_info: &ModelInfo, - detail: Option, -) -> Option { - match detail { - Some(ImageDetail::Original) if can_request_original_image_detail(features, model_info) => { - Some(ImageDetail::Original) - } - Some(ImageDetail::Original) | Some(_) | None => None, - } -} - -#[cfg(test)] -#[path = "original_image_detail_tests.rs"] -mod tests; +pub(crate) use codex_tools::can_request_original_image_detail; +pub(crate) use codex_tools::normalize_output_image_detail; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index d0614fc35..3e9f2f6fa 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,8 +1,6 @@ use crate::client_common::tools::ToolSpec; -use crate::config::AgentRoleConfig; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; -use crate::original_image_detail::can_request_original_image_detail; use crate::shell::Shell; use crate::shell::ShellType; use crate::tools::code_mode::PUBLIC_TOOL_NAME; @@ -21,21 +19,11 @@ use crate::tools::handlers::request_permissions_tool_description; use crate::tools::handlers::request_user_input_tool_description; use crate::tools::registry::ToolRegistryBuilder; use crate::tools::registry::tool_handler_key; -use codex_features::Feature; -use codex_features::Features; -use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; -use codex_protocol::openai_models::InputModality; -use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::WebSearchToolType; -use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::SessionSource; -use codex_protocol::protocol::SubAgentSource; use codex_tools::CommandToolOptions; use codex_tools::DiscoverableTool; use codex_tools::DiscoverableToolType; @@ -43,6 +31,7 @@ use codex_tools::ShellToolOptions; use codex_tools::SpawnAgentToolOptions; use codex_tools::ToolSearchAppInfo; use codex_tools::ToolSuggestEntry; +use codex_tools::ToolUserShellType; use codex_tools::ViewImageToolOptions; use codex_tools::WaitAgentTimeoutOptions; use codex_tools::augment_tool_spec_for_code_mode; @@ -80,282 +69,38 @@ use codex_tools::create_write_stdin_tool; use codex_tools::dynamic_tool_to_responses_api_tool; use codex_tools::mcp_tool_to_responses_api_tool; use codex_tools::tool_spec_to_code_mode_tool_definition; -use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; -use std::collections::BTreeMap; use std::collections::HashMap; -use std::path::PathBuf; pub type JsonSchema = codex_tools::JsonSchema; +pub use codex_tools::ShellCommandBackendConfig; +pub use codex_tools::ToolsConfig; +pub use codex_tools::ToolsConfigParams; +pub use codex_tools::UnifiedExecShellMode; +pub use codex_tools::ZshForkConfig; #[cfg(test)] pub(crate) use codex_tools::mcp_call_tool_result_output_schema; const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"]; -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum ShellCommandBackendConfig { - Classic, - ZshFork, -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum UnifiedExecShellMode { - Direct, - ZshFork(ZshForkConfig), -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct ZshForkConfig { - pub(crate) shell_zsh_path: AbsolutePathBuf, - pub(crate) main_execve_wrapper_exe: AbsolutePathBuf, -} - -impl UnifiedExecShellMode { - pub fn for_session( - shell_command_backend: ShellCommandBackendConfig, - user_shell: &Shell, - shell_zsh_path: Option<&PathBuf>, - main_execve_wrapper_exe: Option<&PathBuf>, - ) -> Self { - if cfg!(unix) - && shell_command_backend == ShellCommandBackendConfig::ZshFork - && matches!(user_shell.shell_type, ShellType::Zsh) - && let (Some(shell_zsh_path), Some(main_execve_wrapper_exe)) = - (shell_zsh_path, main_execve_wrapper_exe) - && let (Ok(shell_zsh_path), Ok(main_execve_wrapper_exe)) = ( - AbsolutePathBuf::try_from(shell_zsh_path.as_path()) - .inspect_err(|e| tracing::warn!("Failed to convert shell_zsh_path `{shell_zsh_path:?}`: {e:?}")), - AbsolutePathBuf::try_from(main_execve_wrapper_exe.as_path()).inspect_err(|e| { - tracing::warn!("Failed to convert main_execve_wrapper_exe `{main_execve_wrapper_exe:?}`: {e:?}") - }), - ) - { - Self::ZshFork(ZshForkConfig { - shell_zsh_path, - main_execve_wrapper_exe, - }) - } else { - Self::Direct - } +pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType { + match user_shell.shell_type { + ShellType::Zsh => ToolUserShellType::Zsh, + ShellType::Bash => ToolUserShellType::Bash, + ShellType::PowerShell => ToolUserShellType::PowerShell, + ShellType::Sh => ToolUserShellType::Sh, + ShellType::Cmd => ToolUserShellType::Cmd, } } -#[derive(Debug, Clone)] -pub(crate) struct ToolsConfig { - pub available_models: Vec, - pub shell_type: ConfigShellToolType, - shell_command_backend: ShellCommandBackendConfig, - pub unified_exec_shell_mode: UnifiedExecShellMode, - pub allow_login_shell: bool, - pub apply_patch_tool_type: Option, - pub web_search_mode: Option, - pub web_search_config: Option, - pub web_search_tool_type: WebSearchToolType, - pub image_gen_tool: bool, - pub agent_roles: BTreeMap, - pub search_tool: bool, - pub tool_suggest: bool, - pub exec_permission_approvals_enabled: bool, - pub request_permissions_tool_enabled: bool, - pub code_mode_enabled: bool, - pub code_mode_only_enabled: bool, - pub js_repl_enabled: bool, - pub js_repl_tools_only: bool, - pub can_request_original_image_detail: bool, - pub collab_tools: bool, - pub multi_agent_v2: bool, - pub request_user_input: bool, - pub default_mode_request_user_input: bool, - pub experimental_supported_tools: Vec, - pub agent_jobs_tools: bool, - pub agent_jobs_worker_tools: bool, -} - -pub(crate) struct ToolsConfigParams<'a> { - pub(crate) model_info: &'a ModelInfo, - pub(crate) available_models: &'a Vec, - pub(crate) features: &'a Features, - pub(crate) web_search_mode: Option, - pub(crate) session_source: SessionSource, - pub(crate) sandbox_policy: &'a SandboxPolicy, - pub(crate) windows_sandbox_level: WindowsSandboxLevel, -} - -fn unified_exec_allowed_in_environment( - is_windows: bool, - sandbox_policy: &SandboxPolicy, - windows_sandbox_level: WindowsSandboxLevel, -) -> bool { - !(is_windows - && windows_sandbox_level != WindowsSandboxLevel::Disabled - && !matches!( - sandbox_policy, - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } - )) -} - -impl ToolsConfig { - pub fn new(params: &ToolsConfigParams) -> Self { - let ToolsConfigParams { - model_info, - available_models: available_models_ref, - features, - web_search_mode, - session_source, - sandbox_policy, - windows_sandbox_level, - } = params; - let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); - let include_code_mode = features.enabled(Feature::CodeMode); - let include_code_mode_only = include_code_mode && features.enabled(Feature::CodeModeOnly); - let include_js_repl = features.enabled(Feature::JsRepl); - let include_js_repl_tools_only = - include_js_repl && features.enabled(Feature::JsReplToolsOnly); - let include_collab_tools = features.enabled(Feature::Collab); - let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2); - let include_agent_jobs = features.enabled(Feature::SpawnCsv); - let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_)); - let include_default_mode_request_user_input = - include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); - let include_search_tool = - model_info.supports_search_tool && features.enabled(Feature::ToolSearch); - let include_tool_suggest = features.enabled(Feature::ToolSuggest) - && features.enabled(Feature::Apps) - && features.enabled(Feature::Plugins); - let include_original_image_detail = can_request_original_image_detail(features, model_info); - let include_image_gen_tool = - features.enabled(Feature::ImageGeneration) && supports_image_generation(model_info); - let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals); - let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool); - let shell_command_backend = - if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { - ShellCommandBackendConfig::ZshFork - } else { - ShellCommandBackendConfig::Classic - }; - let unified_exec_allowed = unified_exec_allowed_in_environment( - cfg!(target_os = "windows"), - sandbox_policy, - *windows_sandbox_level, - ); - let shell_type = if !features.enabled(Feature::ShellTool) { - ConfigShellToolType::Disabled - } else if features.enabled(Feature::ShellZshFork) { - ConfigShellToolType::ShellCommand - } else if features.enabled(Feature::UnifiedExec) && unified_exec_allowed { - // If ConPTY not supported (for old Windows versions), fallback on ShellCommand. - if codex_utils_pty::conpty_supported() { - ConfigShellToolType::UnifiedExec - } else { - ConfigShellToolType::ShellCommand - } - } else if model_info.shell_type == ConfigShellToolType::UnifiedExec && !unified_exec_allowed - { - ConfigShellToolType::ShellCommand - } else { - model_info.shell_type - }; - - let apply_patch_tool_type = match model_info.apply_patch_tool_type { - Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform), - Some(ApplyPatchToolType::Function) => Some(ApplyPatchToolType::Function), - None => { - if include_apply_patch_tool { - Some(ApplyPatchToolType::Freeform) - } else { - None - } - } - }; - - let agent_jobs_worker_tools = include_agent_jobs - && matches!( - session_source, - SessionSource::SubAgent(SubAgentSource::Other(label)) - if label.starts_with("agent_job:") - ); - - Self { - available_models: available_models_ref.to_vec(), - shell_type, - shell_command_backend, - unified_exec_shell_mode: UnifiedExecShellMode::Direct, - allow_login_shell: true, - apply_patch_tool_type, - web_search_mode: *web_search_mode, - web_search_config: None, - web_search_tool_type: model_info.web_search_tool_type, - image_gen_tool: include_image_gen_tool, - agent_roles: BTreeMap::new(), - search_tool: include_search_tool, - tool_suggest: include_tool_suggest, - exec_permission_approvals_enabled, - request_permissions_tool_enabled, - code_mode_enabled: include_code_mode, - code_mode_only_enabled: include_code_mode_only, - js_repl_enabled: include_js_repl, - js_repl_tools_only: include_js_repl_tools_only, - can_request_original_image_detail: include_original_image_detail, - collab_tools: include_collab_tools, - multi_agent_v2: include_multi_agent_v2, - request_user_input: include_request_user_input, - default_mode_request_user_input: include_default_mode_request_user_input, - experimental_supported_tools: model_info.experimental_supported_tools.clone(), - agent_jobs_tools: include_agent_jobs, - agent_jobs_worker_tools, - } +fn agent_type_description(config: &ToolsConfig) -> String { + if config.agent_type_description.is_empty() { + crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new()) + } else { + config.agent_type_description.clone() } - - pub fn with_agent_roles(mut self, agent_roles: BTreeMap) -> Self { - self.agent_roles = agent_roles; - self - } - - pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self { - self.allow_login_shell = allow_login_shell; - self - } - - pub fn with_unified_exec_shell_mode( - mut self, - unified_exec_shell_mode: UnifiedExecShellMode, - ) -> Self { - self.unified_exec_shell_mode = unified_exec_shell_mode; - self - } - - pub fn with_unified_exec_shell_mode_for_session( - mut self, - user_shell: &Shell, - shell_zsh_path: Option<&PathBuf>, - main_execve_wrapper_exe: Option<&PathBuf>, - ) -> Self { - self.unified_exec_shell_mode = UnifiedExecShellMode::for_session( - self.shell_command_backend, - user_shell, - shell_zsh_path, - main_execve_wrapper_exe, - ); - self - } - - pub fn with_web_search_config(mut self, web_search_config: Option) -> Self { - self.web_search_config = web_search_config; - self - } - - pub fn for_code_mode_nested_tools(&self) -> Self { - let mut nested = self.clone(); - nested.code_mode_enabled = false; - nested.code_mode_only_enabled = false; - nested - } -} - -fn supports_image_generation(model_info: &ModelInfo) -> bool { - model_info.input_modalities.contains(&InputModality::Image) } /// TODO(dylan): deprecate once we get rid of json tool @@ -775,13 +520,12 @@ pub(crate) fn build_specs_with_discoverable_tools( if config.collab_tools { if config.multi_agent_v2 { + let agent_type_description = agent_type_description(config); push_tool_spec( &mut builder, create_spawn_agent_tool_v2(SpawnAgentToolOptions { available_models: &config.available_models, - agent_type_description: crate::agent::role::spawn_tool_spec::build( - &config.agent_roles, - ), + agent_type_description, }), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, @@ -827,13 +571,12 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("close_agent", Arc::new(CloseAgentHandlerV2)); builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2)); } else { + let agent_type_description = agent_type_description(config); push_tool_spec( &mut builder, create_spawn_agent_tool_v1(SpawnAgentToolOptions { available_models: &config.available_models, - agent_type_description: crate::agent::role::spawn_tool_spec::build( - &config.agent_roles, - ), + agent_type_description, }), /*supports_parallel_tool_calls*/ false, config.code_mode_enabled, diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index e2dd827d6..157d962b4 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -6,10 +6,18 @@ use crate::shell::ShellType; use crate::tools::ToolRouter; use crate::tools::router::ToolRouterParams; use codex_app_server_protocol::AppInfo; +use codex_features::Feature; +use codex_features::Features; +use codex_protocol::config_types::WebSearchConfig; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::VIEW_IMAGE_TOOL_NAME; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_tools::AdditionalProperties; use codex_tools::CommandToolOptions; use codex_tools::ConfiguredToolSpec; @@ -40,6 +48,7 @@ use codex_tools::mcp_tool_to_deferred_responses_api_tool; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::BTreeMap; use std::path::PathBuf; use super::*; @@ -178,7 +187,7 @@ fn request_user_input_tool_spec(default_mode_request_user_input: bool) -> ToolSp fn spawn_agent_tool_options(config: &ToolsConfig) -> SpawnAgentToolOptions<'_> { SpawnAgentToolOptions { available_models: &config.available_models, - agent_type_description: crate::agent::role::spawn_tool_spec::build(&config.agent_roles), + agent_type_description: agent_type_description(config), } } @@ -248,30 +257,6 @@ fn model_info_from_models_json(slug: &str) -> ModelInfo { with_config_overrides(model, &config) } -#[test] -fn unified_exec_is_blocked_for_windows_sandboxed_policies_only() { - assert!(!unified_exec_allowed_in_environment( - /*is_windows*/ true, - &SandboxPolicy::new_read_only_policy(), - WindowsSandboxLevel::RestrictedToken, - )); - assert!(!unified_exec_allowed_in_environment( - /*is_windows*/ true, - &SandboxPolicy::new_workspace_write_policy(), - WindowsSandboxLevel::RestrictedToken, - )); - assert!(unified_exec_allowed_in_environment( - /*is_windows*/ true, - &SandboxPolicy::DangerFullAccess, - WindowsSandboxLevel::RestrictedToken, - )); - assert!(unified_exec_allowed_in_environment( - /*is_windows*/ true, - &SandboxPolicy::DangerFullAccess, - WindowsSandboxLevel::Disabled, - )); -} - #[test] fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() { let mut model_info = model_info_from_models_json("gpt-5-codex"); @@ -1642,7 +1627,7 @@ fn shell_zsh_fork_prefers_shell_command_over_unified_exec() { assert_eq!( tools_config .with_unified_exec_shell_mode_for_session( - &user_shell, + tool_user_shell_type(&user_shell), Some(&PathBuf::from(if cfg!(windows) { r"C:\opt\codex\zsh" } else { diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index 3d61dab7d..179681003 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -10,7 +10,10 @@ workspace = true [dependencies] codex-app-server-protocol = { workspace = true } codex-code-mode = { workspace = true } +codex-features = { workspace = true } codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-pty = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ "base64", "macros", @@ -19,6 +22,7 @@ rmcp = { workspace = true, default-features = false, features = [ ] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +tracing = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/tools/src/image_detail.rs b/codex-rs/tools/src/image_detail.rs new file mode 100644 index 000000000..93c11aee8 --- /dev/null +++ b/codex-rs/tools/src/image_detail.rs @@ -0,0 +1,25 @@ +use codex_features::Feature; +use codex_features::Features; +use codex_protocol::models::ImageDetail; +use codex_protocol::openai_models::ModelInfo; + +pub fn can_request_original_image_detail(features: &Features, model_info: &ModelInfo) -> bool { + model_info.supports_image_detail_original && features.enabled(Feature::ImageDetailOriginal) +} + +pub fn normalize_output_image_detail( + features: &Features, + model_info: &ModelInfo, + detail: Option, +) -> Option { + match detail { + Some(ImageDetail::Original) if can_request_original_image_detail(features, model_info) => { + Some(ImageDetail::Original) + } + Some(ImageDetail::Original) | Some(_) | None => None, + } +} + +#[cfg(test)] +#[path = "image_detail_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/original_image_detail_tests.rs b/codex-rs/tools/src/image_detail_tests.rs similarity index 50% rename from codex-rs/core/src/original_image_detail_tests.rs rename to codex-rs/tools/src/image_detail_tests.rs index 05094e0a6..cfaeb7fb2 100644 --- a/codex-rs/core/src/original_image_detail_tests.rs +++ b/codex-rs/tools/src/image_detail_tests.rs @@ -1,16 +1,49 @@ use super::*; - -use crate::config::test_config; -use crate::models_manager::manager::ModelsManager; +use codex_features::Feature; use codex_features::Features; +use codex_protocol::models::ImageDetail; +use codex_protocol::openai_models::ModelInfo; use pretty_assertions::assert_eq; +use serde_json::json; + +fn model_info() -> ModelInfo { + serde_json::from_value(json!({ + "slug": "test-model", + "display_name": "Test Model", + "description": null, + "supported_reasoning_levels": [], + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 1, + "availability_nux": null, + "upgrade": null, + "base_instructions": "base", + "model_messages": null, + "supports_reasoning_summaries": false, + "default_reasoning_summary": "auto", + "support_verbosity": false, + "default_verbosity": null, + "apply_patch_tool_type": null, + "truncation_policy": { + "mode": "bytes", + "limit": 10000 + }, + "supports_parallel_tool_calls": false, + "supports_image_detail_original": true, + "context_window": null, + "auto_compact_token_limit": null, + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": ["text", "image"], + "supports_search_tool": false + })) + .expect("deserialize test model") +} #[test] fn image_detail_original_feature_enables_explicit_original_without_force() { - let config = test_config(); - let mut model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - model_info.supports_image_detail_original = true; + let model_info = model_info(); let mut features = Features::with_defaults(); features.enable(Feature::ImageDetailOriginal); @@ -27,10 +60,7 @@ fn image_detail_original_feature_enables_explicit_original_without_force() { #[test] fn explicit_original_is_dropped_without_feature_or_model_support() { - let config = test_config(); - let mut model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - model_info.supports_image_detail_original = true; + let mut model_info = model_info(); let features = Features::with_defaults(); assert_eq!( @@ -49,10 +79,7 @@ fn explicit_original_is_dropped_without_feature_or_model_support() { #[test] fn unsupported_non_original_detail_is_dropped() { - let config = test_config(); - let mut model_info = - ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); - model_info.supports_image_detail_original = true; + let model_info = model_info(); let mut features = Features::with_defaults(); features.enable(Feature::ImageDetailOriginal); diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 9ae6031cc..77622eed3 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -5,6 +5,7 @@ mod agent_job_tool; mod agent_tool; mod code_mode; mod dynamic_tool; +mod image_detail; mod js_repl_tool; mod json_schema; mod local_tool; @@ -12,6 +13,7 @@ mod mcp_resource_tool; mod mcp_tool; mod request_user_input_tool; mod responses_api; +mod tool_config; mod tool_definition; mod tool_discovery; mod tool_spec; @@ -38,6 +40,8 @@ pub use code_mode::create_code_mode_tool; pub use code_mode::create_wait_tool; pub use code_mode::tool_spec_to_code_mode_tool_definition; pub use dynamic_tool::parse_dynamic_tool; +pub use image_detail::can_request_original_image_detail; +pub use image_detail::normalize_output_image_detail; pub use js_repl_tool::create_js_repl_reset_tool; pub use js_repl_tool::create_js_repl_tool; pub use json_schema::AdditionalProperties; @@ -66,6 +70,12 @@ pub use responses_api::dynamic_tool_to_responses_api_tool; pub use responses_api::mcp_tool_to_deferred_responses_api_tool; pub use responses_api::mcp_tool_to_responses_api_tool; pub use responses_api::tool_definition_to_responses_api_tool; +pub use tool_config::ShellCommandBackendConfig; +pub use tool_config::ToolUserShellType; +pub use tool_config::ToolsConfig; +pub use tool_config::ToolsConfigParams; +pub use tool_config::UnifiedExecShellMode; +pub use tool_config::ZshForkConfig; pub use tool_definition::ToolDefinition; pub use tool_discovery::DiscoverablePluginInfo; pub use tool_discovery::DiscoverableTool; diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs new file mode 100644 index 000000000..f48f1b8fc --- /dev/null +++ b/codex-rs/tools/src/tool_config.rs @@ -0,0 +1,294 @@ +use crate::can_request_original_image_detail; +use codex_features::Feature; +use codex_features::Features; +use codex_protocol::config_types::WebSearchConfig; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::openai_models::ApplyPatchToolType; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::InputModality; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::WebSearchToolType; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ShellCommandBackendConfig { + Classic, + ZshFork, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ToolUserShellType { + Zsh, + Bash, + PowerShell, + Sh, + Cmd, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub enum UnifiedExecShellMode { + Direct, + ZshFork(ZshForkConfig), +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ZshForkConfig { + pub shell_zsh_path: AbsolutePathBuf, + pub main_execve_wrapper_exe: AbsolutePathBuf, +} + +impl UnifiedExecShellMode { + pub fn for_session( + shell_command_backend: ShellCommandBackendConfig, + user_shell_type: ToolUserShellType, + shell_zsh_path: Option<&PathBuf>, + main_execve_wrapper_exe: Option<&PathBuf>, + ) -> Self { + if cfg!(unix) + && shell_command_backend == ShellCommandBackendConfig::ZshFork + && matches!(user_shell_type, ToolUserShellType::Zsh) + && let (Some(shell_zsh_path), Some(main_execve_wrapper_exe)) = + (shell_zsh_path, main_execve_wrapper_exe) + && let (Ok(shell_zsh_path), Ok(main_execve_wrapper_exe)) = ( + AbsolutePathBuf::try_from(shell_zsh_path.as_path()).inspect_err(|err| { + tracing::warn!( + "Failed to convert shell_zsh_path `{shell_zsh_path:?}`: {err:?}" + ) + }), + AbsolutePathBuf::try_from(main_execve_wrapper_exe.as_path()).inspect_err( + |err| { + tracing::warn!( + "Failed to convert main_execve_wrapper_exe `{main_execve_wrapper_exe:?}`: {err:?}" + ) + }, + ), + ) + { + Self::ZshFork(ZshForkConfig { + shell_zsh_path, + main_execve_wrapper_exe, + }) + } else { + Self::Direct + } + } +} + +#[derive(Debug, Clone)] +pub struct ToolsConfig { + pub available_models: Vec, + pub shell_type: ConfigShellToolType, + pub shell_command_backend: ShellCommandBackendConfig, + pub unified_exec_shell_mode: UnifiedExecShellMode, + pub allow_login_shell: bool, + pub apply_patch_tool_type: Option, + pub web_search_mode: Option, + pub web_search_config: Option, + pub web_search_tool_type: WebSearchToolType, + pub image_gen_tool: bool, + pub search_tool: bool, + pub tool_suggest: bool, + pub exec_permission_approvals_enabled: bool, + pub request_permissions_tool_enabled: bool, + pub code_mode_enabled: bool, + pub code_mode_only_enabled: bool, + pub js_repl_enabled: bool, + pub js_repl_tools_only: bool, + pub can_request_original_image_detail: bool, + pub collab_tools: bool, + pub multi_agent_v2: bool, + pub request_user_input: bool, + pub default_mode_request_user_input: bool, + pub experimental_supported_tools: Vec, + pub agent_jobs_tools: bool, + pub agent_jobs_worker_tools: bool, + pub agent_type_description: String, +} + +pub struct ToolsConfigParams<'a> { + pub model_info: &'a ModelInfo, + pub available_models: &'a [ModelPreset], + pub features: &'a Features, + pub web_search_mode: Option, + pub session_source: SessionSource, + pub sandbox_policy: &'a SandboxPolicy, + pub windows_sandbox_level: WindowsSandboxLevel, +} + +impl ToolsConfig { + pub fn new(params: &ToolsConfigParams<'_>) -> Self { + let ToolsConfigParams { + model_info, + available_models, + features, + web_search_mode, + session_source, + sandbox_policy, + windows_sandbox_level, + } = params; + let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); + let include_code_mode = features.enabled(Feature::CodeMode); + let include_code_mode_only = include_code_mode && features.enabled(Feature::CodeModeOnly); + let include_js_repl = features.enabled(Feature::JsRepl); + let include_js_repl_tools_only = + include_js_repl && features.enabled(Feature::JsReplToolsOnly); + let include_collab_tools = features.enabled(Feature::Collab); + let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2); + let include_agent_jobs = features.enabled(Feature::SpawnCsv); + let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_)); + let include_default_mode_request_user_input = + include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); + let include_search_tool = + model_info.supports_search_tool && features.enabled(Feature::ToolSearch); + let include_tool_suggest = features.enabled(Feature::ToolSuggest) + && features.enabled(Feature::Apps) + && features.enabled(Feature::Plugins); + let include_original_image_detail = can_request_original_image_detail(features, model_info); + let include_image_gen_tool = + features.enabled(Feature::ImageGeneration) && supports_image_generation(model_info); + let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals); + let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool); + let shell_command_backend = + if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { + ShellCommandBackendConfig::ZshFork + } else { + ShellCommandBackendConfig::Classic + }; + let unified_exec_allowed = unified_exec_allowed_in_environment( + cfg!(target_os = "windows"), + sandbox_policy, + *windows_sandbox_level, + ); + let shell_type = if !features.enabled(Feature::ShellTool) { + ConfigShellToolType::Disabled + } else if features.enabled(Feature::ShellZshFork) { + ConfigShellToolType::ShellCommand + } else if features.enabled(Feature::UnifiedExec) && unified_exec_allowed { + if codex_utils_pty::conpty_supported() { + ConfigShellToolType::UnifiedExec + } else { + ConfigShellToolType::ShellCommand + } + } else if model_info.shell_type == ConfigShellToolType::UnifiedExec && !unified_exec_allowed + { + ConfigShellToolType::ShellCommand + } else { + model_info.shell_type + }; + + let apply_patch_tool_type = match model_info.apply_patch_tool_type { + Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform), + Some(ApplyPatchToolType::Function) => Some(ApplyPatchToolType::Function), + None => include_apply_patch_tool.then_some(ApplyPatchToolType::Freeform), + }; + + let agent_jobs_worker_tools = include_agent_jobs + && matches!( + session_source, + SessionSource::SubAgent(SubAgentSource::Other(label)) + if label.starts_with("agent_job:") + ); + + Self { + available_models: available_models.to_vec(), + shell_type, + shell_command_backend, + unified_exec_shell_mode: UnifiedExecShellMode::Direct, + allow_login_shell: true, + apply_patch_tool_type, + web_search_mode: *web_search_mode, + web_search_config: None, + web_search_tool_type: model_info.web_search_tool_type, + image_gen_tool: include_image_gen_tool, + search_tool: include_search_tool, + tool_suggest: include_tool_suggest, + exec_permission_approvals_enabled, + request_permissions_tool_enabled, + code_mode_enabled: include_code_mode, + code_mode_only_enabled: include_code_mode_only, + js_repl_enabled: include_js_repl, + js_repl_tools_only: include_js_repl_tools_only, + can_request_original_image_detail: include_original_image_detail, + collab_tools: include_collab_tools, + multi_agent_v2: include_multi_agent_v2, + request_user_input: include_request_user_input, + default_mode_request_user_input: include_default_mode_request_user_input, + experimental_supported_tools: model_info.experimental_supported_tools.clone(), + agent_jobs_tools: include_agent_jobs, + agent_jobs_worker_tools, + agent_type_description: String::new(), + } + } + + pub fn with_agent_type_description(mut self, agent_type_description: String) -> Self { + self.agent_type_description = agent_type_description; + self + } + + pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self { + self.allow_login_shell = allow_login_shell; + self + } + + pub fn with_unified_exec_shell_mode( + mut self, + unified_exec_shell_mode: UnifiedExecShellMode, + ) -> Self { + self.unified_exec_shell_mode = unified_exec_shell_mode; + self + } + + pub fn with_unified_exec_shell_mode_for_session( + mut self, + user_shell_type: ToolUserShellType, + shell_zsh_path: Option<&PathBuf>, + main_execve_wrapper_exe: Option<&PathBuf>, + ) -> Self { + self.unified_exec_shell_mode = UnifiedExecShellMode::for_session( + self.shell_command_backend, + user_shell_type, + shell_zsh_path, + main_execve_wrapper_exe, + ); + self + } + + pub fn with_web_search_config(mut self, web_search_config: Option) -> Self { + self.web_search_config = web_search_config; + self + } + + pub fn for_code_mode_nested_tools(&self) -> Self { + let mut nested = self.clone(); + nested.code_mode_enabled = false; + nested.code_mode_only_enabled = false; + nested + } +} + +fn supports_image_generation(model_info: &ModelInfo) -> bool { + model_info.input_modalities.contains(&InputModality::Image) +} + +fn unified_exec_allowed_in_environment( + is_windows: bool, + sandbox_policy: &SandboxPolicy, + windows_sandbox_level: WindowsSandboxLevel, +) -> bool { + !(is_windows + && windows_sandbox_level != WindowsSandboxLevel::Disabled + && !matches!( + sandbox_policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + )) +} + +#[cfg(test)] +#[path = "tool_config_tests.rs"] +mod tests; diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs new file mode 100644 index 000000000..168c8e80a --- /dev/null +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -0,0 +1,200 @@ +use super::*; +use codex_features::Feature; +use codex_features::Features; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::InputModality; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::PathBuf; + +fn model_info() -> ModelInfo { + serde_json::from_value(json!({ + "slug": "test-model", + "display_name": "Test Model", + "description": null, + "supported_reasoning_levels": [], + "shell_type": "unified_exec", + "visibility": "list", + "supported_in_api": true, + "priority": 1, + "availability_nux": null, + "upgrade": null, + "base_instructions": "base", + "model_messages": null, + "supports_reasoning_summaries": false, + "default_reasoning_summary": "auto", + "support_verbosity": false, + "default_verbosity": null, + "apply_patch_tool_type": null, + "truncation_policy": { + "mode": "bytes", + "limit": 10000 + }, + "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, + "context_window": null, + "auto_compact_token_limit": null, + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": ["text", "image"], + "supports_search_tool": false + })) + .expect("deserialize test model") +} + +#[test] +fn unified_exec_is_blocked_for_windows_sandboxed_policies_only() { + assert!(!unified_exec_allowed_in_environment( + /*is_windows*/ true, + &SandboxPolicy::new_read_only_policy(), + WindowsSandboxLevel::RestrictedToken, + )); + assert!(!unified_exec_allowed_in_environment( + /*is_windows*/ true, + &SandboxPolicy::new_workspace_write_policy(), + WindowsSandboxLevel::RestrictedToken, + )); + assert!(unified_exec_allowed_in_environment( + /*is_windows*/ true, + &SandboxPolicy::DangerFullAccess, + WindowsSandboxLevel::RestrictedToken, + )); + assert!(unified_exec_allowed_in_environment( + /*is_windows*/ true, + &SandboxPolicy::DangerFullAccess, + WindowsSandboxLevel::Disabled, + )); +} + +#[test] +fn shell_zsh_fork_prefers_shell_command_over_unified_exec() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.enable(Feature::UnifiedExec); + features.enable(Feature::ShellZshFork); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Live), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + + assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand); + assert_eq!( + tools_config.shell_command_backend, + ShellCommandBackendConfig::ZshFork + ); + assert_eq!( + tools_config.unified_exec_shell_mode, + UnifiedExecShellMode::Direct + ); + assert_eq!( + tools_config + .with_unified_exec_shell_mode_for_session( + ToolUserShellType::Zsh, + Some(&PathBuf::from(if cfg!(windows) { + r"C:\opt\codex\zsh" + } else { + "/opt/codex/zsh" + })), + Some(&PathBuf::from(if cfg!(windows) { + r"C:\opt\codex\codex-execve-wrapper" + } else { + "/opt/codex/codex-execve-wrapper" + })), + ) + .unified_exec_shell_mode, + if cfg!(unix) { + UnifiedExecShellMode::ZshFork(ZshForkConfig { + shell_zsh_path: AbsolutePathBuf::from_absolute_path("/opt/codex/zsh").unwrap(), + main_execve_wrapper_exe: AbsolutePathBuf::from_absolute_path( + "/opt/codex/codex-execve-wrapper", + ) + .unwrap(), + }) + } else { + UnifiedExecShellMode::Direct + } + ); +} + +#[test] +fn subagents_disable_request_user_input_and_agent_jobs_workers_opt_in_by_label() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.enable(Feature::SpawnCsv); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::SubAgent(SubAgentSource::Other( + "agent_job:test".to_string(), + )), + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + + assert!(!tools_config.request_user_input); + assert!(!tools_config.default_mode_request_user_input); + assert!(tools_config.agent_jobs_tools); + assert!(tools_config.agent_jobs_worker_tools); +} + +#[test] +fn image_generation_requires_feature_and_supported_model() { + let supported_model_info = model_info(); + let mut unsupported_model_info = supported_model_info.clone(); + unsupported_model_info.input_modalities = vec![InputModality::Text]; + + let default_features = Features::with_defaults(); + let mut image_generation_features = default_features.clone(); + image_generation_features.enable(Feature::ImageGeneration); + + let available_models = Vec::new(); + let default_tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &supported_model_info, + available_models: &available_models, + features: &default_features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let supported_tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &supported_model_info, + available_models: &available_models, + features: &image_generation_features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let unsupported_tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &unsupported_model_info, + available_models: &available_models, + features: &image_generation_features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + + assert!(!default_tools_config.image_gen_tool); + assert!(supported_tools_config.image_gen_tool); + assert!(!unsupported_tools_config.image_gen_tool); +}