diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 209db995b3..1009e4c1da 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7094,6 +7094,16 @@ ], "description": "Per-account approval settings keyed by link ID." }, + "omit_tools_from": { + "description": "Additional model-facing surfaces omitted for this connector's tools.", + "items": { + "$ref": "#/definitions/v2/ToolExposureSurface" + }, + "type": [ + "array", + "null" + ] + }, "open_world_enabled": { "type": [ "boolean", @@ -24873,6 +24883,32 @@ ], "type": "object" }, + "ToolExposureSurface": { + "description": "A model-facing surface on which a tool can be exposed.", + "oneOf": [ + { + "description": "Nested tools available to Code Mode scripts.", + "enum": [ + "code_mode" + ], + "type": "string" + }, + { + "description": "Tools discovered later through tool search.", + "enum": [ + "deferred" + ], + "type": "string" + }, + { + "description": "Tools present in the model's initial tool list.", + "enum": [ + "direct" + ], + "type": "string" + } + ] + }, "ToolsV2": { "properties": { "web_search": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 56226be9c7..d122965b2f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -510,6 +510,16 @@ ], "description": "Per-account approval settings keyed by link ID." }, + "omit_tools_from": { + "description": "Additional model-facing surfaces omitted for this connector's tools.", + "items": { + "$ref": "#/definitions/ToolExposureSurface" + }, + "type": [ + "array", + "null" + ] + }, "open_world_enabled": { "type": [ "boolean", @@ -22587,6 +22597,32 @@ ], "type": "object" }, + "ToolExposureSurface": { + "description": "A model-facing surface on which a tool can be exposed.", + "oneOf": [ + { + "description": "Nested tools available to Code Mode scripts.", + "enum": [ + "code_mode" + ], + "type": "string" + }, + { + "description": "Tools discovered later through tool search.", + "enum": [ + "deferred" + ], + "type": "string" + }, + { + "description": "Tools present in the model's initial tool list.", + "enum": [ + "direct" + ], + "type": "string" + } + ] + }, "ToolsV2": { "properties": { "web_search": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 0a8930db24..66a190dfe6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -73,6 +73,16 @@ ], "description": "Per-account approval settings keyed by link ID." }, + "omit_tools_from": { + "description": "Additional model-facing surfaces omitted for this connector's tools.", + "items": { + "$ref": "#/definitions/ToolExposureSurface" + }, + "type": [ + "array", + "null" + ] + }, "open_world_enabled": { "type": [ "boolean", @@ -1009,6 +1019,32 @@ }, "type": "object" }, + "ToolExposureSurface": { + "description": "A model-facing surface on which a tool can be exposed.", + "oneOf": [ + { + "description": "Nested tools available to Code Mode scripts.", + "enum": [ + "code_mode" + ], + "type": "string" + }, + { + "description": "Tools discovered later through tool search.", + "enum": [ + "deferred" + ], + "type": "string" + }, + { + "description": "Tools present in the model's initial tool list.", + "enum": [ + "direct" + ], + "type": "string" + } + ] + }, "ToolsV2": { "properties": { "web_search": { diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 9c0c69a1ae..d1767064f8 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index d93732d82c..06047f35fd 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/ToolExposureSurface.ts b/codex-rs/app-server-protocol/schema/typescript/ToolExposureSurface.ts new file mode 100644 index 0000000000..562a66a08f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/ToolExposureSurface.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A model-facing surface on which a tool can be exposed. + */ +export type ToolExposureSurface = "code_mode" | "deferred" | "direct"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 5f128f7dc6..79f9f52ffc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -85,6 +85,7 @@ export type { SubAgentSource } from "./SubAgentSource"; export type { ThreadId } from "./ThreadId"; export type { ThreadMemoryMode } from "./ThreadMemoryMode"; export type { Tool } from "./Tool"; +export type { ToolExposureSurface } from "./ToolExposureSurface"; export type { Verbosity } from "./Verbosity"; export type { WebSearchAction } from "./WebSearchAction"; export type { WebSearchContextSize } from "./WebSearchContextSize"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts index ea07dc3214..c4fd57630f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppsConfig.ts @@ -1,13 +1,18 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ToolExposureSurface } from "../ToolExposureSurface"; import type { AppLinksConfig } from "./AppLinksConfig"; import type { AppToolApproval } from "./AppToolApproval"; import type { AppToolsConfig } from "./AppToolsConfig"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AppsDefaultConfig } from "./AppsDefaultConfig"; -export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, +export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, +/** + * Additional model-facing surfaces omitted for this connector's tools. + */ +omit_tools_from: Array | null, approvals_reviewer: ApprovalsReviewer | null, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, /** * Per-account approval settings keyed by link ID. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index cda88a7432..7eaade7e5b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -12,6 +12,7 @@ use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ToolExposureSurface; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WebSearchToolConfig; @@ -234,6 +235,8 @@ pub struct AppLinksConfig { pub struct AppConfig { #[serde(default = "default_enabled")] pub enabled: bool, + /// Additional model-facing surfaces omitted for this connector's tools. + pub omit_tools_from: Option>, pub approvals_reviewer: Option, pub destructive_enabled: Option, pub open_world_enabled: Option, diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index 134eb8e74a..7dc90de44b 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -915,6 +915,7 @@ async fn write_value_supports_nested_app_paths() -> Result<()> { value: serde_json::json!({ "app1": { "enabled": false, + "omit_tools_from": ["deferred"], }, }), merge_strategy: MergeStrategy::Replace, @@ -950,6 +951,9 @@ async fn write_value_supports_nested_app_paths() -> Result<()> { "app1".to_string(), AppConfig { enabled: false, + omit_tools_from: Some(vec![ + codex_protocol::config_types::ToolExposureSurface::Deferred + ]), approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index cc0d48f643..347db91b24 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -42,6 +42,7 @@ use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ToolsV2; use codex_app_server_protocol::WriteStatus; use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::ToolExposureSurface; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchContextSize; use codex_protocol::config_types::WebSearchLocation; @@ -1021,6 +1022,7 @@ default_tools_approval_mode = "writes" [apps.app1] enabled = false +omit_tools_from = ["deferred"] approvals_reviewer = "user" destructive_enabled = false default_tools_approval_mode = "prompt" @@ -1035,6 +1037,9 @@ default_tools_approval_mode = "writes" [apps.app_without_links] enabled = true +[apps.app_with_empty_links] +omit_tools_from = [] + [apps.app_with_empty_links.links] "#, )?; @@ -1084,6 +1089,7 @@ enabled = true "app1".to_string(), AppConfig { enabled: false, + omit_tools_from: Some(vec![ToolExposureSurface::Deferred]), approvals_reviewer: Some(ApprovalsReviewer::User), destructive_enabled: Some(false), open_world_enabled: None, @@ -1114,6 +1120,7 @@ enabled = true "app_without_links".to_string(), AppConfig { enabled: true, + omit_tools_from: None, approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, @@ -1127,6 +1134,7 @@ enabled = true "app_with_empty_links".to_string(), AppConfig { enabled: true, + omit_tools_from: Some(vec![]), approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 709c953990..747061d5ab 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -20,6 +20,7 @@ pub use codex_protocol::config_types::ApprovalsReviewer; pub use codex_protocol::config_types::ModeKind; pub use codex_protocol::config_types::Personality; pub use codex_protocol::config_types::ServiceTier; +use codex_protocol::config_types::ToolExposureSurface; pub use codex_protocol::config_types::WebSearchMode; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::BTreeMap; @@ -499,6 +500,12 @@ pub struct AppConfig { #[serde(default = "default_enabled")] pub enabled: bool, + /// Model-facing surfaces from which this connector's tools must be omitted, + /// in addition to any server-level omissions. `None` leaves lower-priority + /// configuration unchanged; an empty list clears connector-level omissions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub omit_tools_from: Option>, + /// Reviewer for approval prompts from this app, overriding the thread default. #[serde(default, skip_serializing_if = "Option::is_none")] pub approvals_reviewer: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1928af9db0..b9c768f6a2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -155,6 +155,13 @@ ], "description": "Per-account approval settings keyed by link ID." }, + "omit_tools_from": { + "description": "Model-facing surfaces from which this connector's tools must be omitted, in addition to any server-level omissions. `None` leaves lower-priority configuration unchanged; an empty list clears connector-level omissions.", + "items": { + "$ref": "#/definitions/ToolExposureSurface" + }, + "type": "array" + }, "open_world_enabled": { "description": "Whether tools with `open_world_hint = true` are allowed for this app.", "type": "boolean" @@ -7230,4 +7237,4 @@ }, "title": "ConfigToml", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index bbc236b3a3..ae1d03b484 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -2341,6 +2341,7 @@ async fn persist_codex_app_tool_approval_writes_tool_override() { "calendar".to_string(), AppConfig { enabled: true, + omit_tools_from: None, approvals_reviewer: None, destructive_enabled: None, open_world_enabled: None, diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 581ba362bf..3cfee595e4 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -61,10 +61,12 @@ use crate::tools::registry::ToolExposure; use crate::tools::registry::ToolRegistry; use crate::tools::router::ToolRouter; use crate::tools::tool_namespaces_info::collect_tool_namespaces_info; +use codex_connectors::apps_config_from_layer_stack; use codex_extension_api::ExtensionData; use codex_features::Feature; use codex_features::SleepToolMode; use codex_login::AuthManager; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_prompts::ResolvedModelMessages; use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; use codex_protocol::account::PlanType; @@ -195,6 +197,7 @@ fn apply_mcp_tool_exposure_policy( registry: &mut ToolRegistry, ) { let mut omitted_exposures_by_tool = HashMap::new(); + let apps_config = apps_config_from_layer_stack(&turn_context.config.config_layer_stack); for tool in mcp.tools() { let tool_name = tool.canonical_tool_name(); if !registered_mcp_tools.contains(&tool_name) { @@ -206,12 +209,19 @@ fn apply_mcp_tool_exposure_policy( omitted_exposures_by_tool .entry(tool_name) .or_insert_with(|| { + let connector_omissions = (tool.server_name == CODEX_APPS_MCP_SERVER_NAME) + .then_some(tool.connector_id.as_deref()) + .flatten() + .and_then(|id| apps_config.as_ref()?.apps.get(id)) + .and_then(|app| app.omit_tools_from.as_deref()) + .unwrap_or_default(); server .config() .omit_tools_from .as_deref() .unwrap_or_default() .iter() + .chain(connector_omissions) .copied() .collect::() }); diff --git a/codex-rs/core/tests/suite/app_tool_exposure.rs b/codex-rs/core/tests/suite/app_tool_exposure.rs new file mode 100644 index 0000000000..977d7b49ff --- /dev/null +++ b/codex-rs/core/tests/suite/app_tool_exposure.rs @@ -0,0 +1,232 @@ +//! Verifies connector-scoped exposure, server restrictions, and MCP dispatch. + +use codex_core::config::Config; +use codex_extension_api::ExtensionFuture; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::McpServerContribution; +use codex_extension_api::McpServerContributionContext; +use codex_extension_api::McpServerContributor; +use codex_features::Feature; +use codex_protocol::config_types::ToolExposureSurface; +use codex_protocol::openai_models::ToolMode; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::apps_test_server::search_capable_apps_builder; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::sync::Arc; +use wiremock::Mock; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_partial_json; +use wiremock::matchers::method; + +struct AppsServer(Vec); + +impl McpServerContributor for AppsServer { + fn id(&self) -> &'static str { + "app_exposure_fixture" + } + + fn contribute<'a>( + &'a self, + context: McpServerContributionContext<'a, Config>, + ) -> ExtensionFuture<'a, Vec> { + Box::pin(async move { + let mut config = codex_mcp::hosted_plugin_runtime_mcp_server_config( + &context.config().chatgpt_base_url, + /*apps_mcp_product_sku*/ None, + context.originator(), + ); + config.omit_tools_from = Some(self.0.clone()); + vec![McpServerContribution::HostedApps { + config: Box::new(config), + protocol_mode: None, + }] + }) + } +} + +pub(super) struct ExposureCase { + mode: ToolMode, + app_config: &'static str, + server_omissions: &'static [&'static str], + direct_only: bool, + expect_direct: bool, + expect_exec: bool, +} + +impl ExposureCase { + pub(super) fn non_deferred(mode: ToolMode) -> Self { + Self { + mode, + app_config: "omit_tools_from = [\"deferred\"]", + server_omissions: &[], + direct_only: false, + expect_direct: mode != ToolMode::CodeModeOnly, + expect_exec: mode != ToolMode::Direct, + } + } +} + +#[test_case::test_case(ToolMode::Direct; "direct")] +#[test_case::test_case(ToolMode::CodeMode; "code_mode")] +#[test_case::test_case(ToolMode::CodeModeOnly; "code_mode_only")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn non_deferred_connector_exposure_and_dispatch(mode: ToolMode) -> anyhow::Result<()> { + connector_exposure_requests(ExposureCase::non_deferred(mode)).await?; + Ok(()) +} + +#[test_case::test_case("", &[], false, false; "unconfigured")] +#[test_case::test_case("omit_tools_from = [\"deferred\"]", &["code_mode"], true, false; "server_excludes_exec")] +#[test_case::test_case("omit_tools_from = [\"deferred\"]", &["direct"], false, true; "server_excludes_direct")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn connector_omissions_respect_server_restrictions( + app_config: &'static str, + server_omissions: &'static [&'static str], + expect_direct: bool, + expect_exec: bool, +) -> anyhow::Result<()> { + connector_exposure_requests(ExposureCase { + app_config, + server_omissions, + expect_direct, + expect_exec, + ..ExposureCase::non_deferred(ToolMode::CodeMode) + }) + .await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn connector_omissions_preserve_direct_only_namespace() -> anyhow::Result<()> { + connector_exposure_requests(ExposureCase { + direct_only: true, + expect_direct: true, + expect_exec: false, + ..ExposureCase::non_deferred(ToolMode::CodeModeOnly) + }) + .await?; + Ok(()) +} + +pub(super) async fn connector_exposure_requests( + case: ExposureCase, +) -> anyhow::Result> { + let server = responses::start_mock_server().await; + AppsTestServer::mount_searchable(&server).await?; + for rpc_method in ["tools/list", "tools/call"] { + Mock::given(method("POST")) + .and(body_partial_json(json!({"method": rpc_method}))) + .respond_with(move |request: &wiremock::Request| { + let body: serde_json::Value = + request.body_json().expect("valid MCP fixture request"); + let result = if rpc_method == "tools/list" { + json!({"tools": [ + { + "name": "lookup", + "description": "Look up calendar events", + "inputSchema": {"type": "object", "properties": {}}, + "annotations": {"readOnlyHint": true}, + "_meta": {"connector_id": "calendar", "connector_name": "calendar"} + }, + { + "name": "read", + "description": "Read notes", + "inputSchema": {"type": "object", "properties": {}}, + "annotations": {"readOnlyHint": true}, + "_meta": {"connector_id": "notes", "connector_name": "notes"} + } + ]}) + } else { + assert_eq!(body["params"]["name"], "lookup"); + json!({"content": [{"type": "text", "text": "calendar-lookup-ok"}]}) + }; + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": body["id"], "result": result + })) + }) + .with_priority(1) + .mount(&server) + .await; + } + let namespace = "mcp__codex_apps__calendar"; + let tool_call = if case.expect_exec { + responses::ev_custom_tool_call( + "lookup", + "exec", + &format!("text((await tools.{namespace}__lookup({{}})).content[0].text);"), + ) + } else if case.expect_direct { + responses::ev_function_call_with_namespace("lookup", namespace, "lookup", "{}") + } else { + responses::ev_assistant_message("message", "done") + }; + let calls_tool = case.expect_direct || case.expect_exec; + let mut events = vec![responses::sse(vec![ + tool_call, + responses::ev_completed("first"), + ])]; + if calls_tool { + events.push(responses::sse(vec![responses::ev_completed("second")])); + } + let response_mock = responses::mount_sse_sequence(&server, events).await; + let auth = codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let mut extensions = ExtensionRegistryBuilder::new(); + extensions.mcp_server_contributor(Arc::new(AppsServer(serde_json::from_value(json!( + case.server_omissions + ))?))); + let mut builder = search_capable_apps_builder(server.uri()) + .with_code_mode_host_program(codex_utils_cargo_bin::cargo_bin("codex-code-mode-host")?) + .with_pre_build_hook(move |home| { + std::fs::write( + home.join("config.toml"), + format!("[apps.calendar]\n{}\n", case.app_config), + ) + .expect("valid fixture configuration"); + }) + .with_auth(auth) + .with_extensions(Arc::new(extensions.build())) + .with_model_info_override("gpt-5.5", move |model| model.tool_mode = Some(case.mode)) + .with_config(move |config| { + if case.direct_only { + config.code_mode.direct_only_tool_namespaces = vec![namespace.to_string()]; + } + config.analytics_enabled = Some(false); + config + .features + .enable(Feature::CodeModeHost) + .expect("code mode host"); + }); + let test = builder.build_with_auto_env(&server).await?; + test.submit_turn("Look up my calendar.").await?; + let requests = response_mock.requests(); + assert_eq!(requests.len(), if calls_tool { 2 } else { 1 }); + assert_eq!( + requests[0].tool_by_name(namespace, "lookup").is_some(), + case.expect_direct + ); + assert!( + requests[0] + .tool_by_name("mcp__codex_apps__notes", "read") + .is_none() + ); + let body = requests[0].body_json(); + let exec_description = body["tools"] + .as_array() + .expect("request tool list") + .iter() + .find(|tool| tool["name"] == "exec") + .and_then(|tool| tool["description"].as_str()) + .unwrap_or_default(); + assert_eq!( + exec_description.contains(&format!("{namespace}__lookup(")), + case.expect_exec && case.mode == ToolMode::CodeModeOnly + ); + assert!(!exec_description.contains("mcp__codex_apps__notes__read(")); + if calls_tool { + assert!(requests[1].body_contains_text("calendar-lookup-ok")); + } + test.codex.shutdown_and_wait().await?; + Ok(requests) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 59e4019e6e..f6e66f5966 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -39,6 +39,7 @@ mod additional_context; mod agent_execution; mod agent_websocket; mod agents_md; +mod app_tool_exposure; mod apply_patch_cli; mod apply_patch_serialization; #[cfg(not(target_os = "windows"))] diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index 3f03e9989d..27784a3ce7 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -783,3 +783,24 @@ async fn guardian_checkpoint_migration_request_history() -> Result<()> { insta::assert_snapshot!("guardian_checkpoint_migration", snapshot); Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn app_tool_exposure_request_history() -> Result<()> { + let requests = super::app_tool_exposure::connector_exposure_requests( + super::app_tool_exposure::ExposureCase::non_deferred( + codex_protocol::openai_models::ToolMode::CodeModeOnly, + ), + ) + .await?; + insta::assert_snapshot!( + "app_tool_exposure_CodeModeOnly", + context_snapshot::format_request_history_snapshot( + "A non-deferred connector is called through code mode while another connector stays deferred.", + &requests, + &ContextSnapshotOptions::default() + .rewrite_known_segments() + .include_request_settings(), + ) + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__app_tool_exposure_CodeModeOnly.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__app_tool_exposure_CodeModeOnly.snap new file mode 100644 index 0000000000..544533d9b4 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__app_tool_exposure_CodeModeOnly.snap @@ -0,0 +1,37 @@ +--- +source: core/tests/suite/scenarios.rs +expression: "context_snapshot::format_request_history_snapshot(\"A non-deferred connector is called through code mode while another connector stays deferred.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: A non-deferred connector is called through code mode while another connector stays deferred. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + instructions: + model: "gpt-5.5" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (4; hash=8f44dd235bfe4473): + - custom/exec: Run JavaScript code to orchestrate/compose tool calls - Evaluates the provided JavaScript code in...; hash=0fc6c2a32798a2ec + - function/wait: Waits on a yielded `exec` cell and returns new output or completion. - Use `wait` only after `exe...; args=[cell_id, max_tokens, terminate, yield_time_ms]; hash=6692d8f6c55be489 + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - web_search; hash=e214d4db1bc49e89 +-- request 1 (turn) -- +00:message/developer[2]: + [01] + [02] +01:message/user: + +02:message/user: + Look up my calendar. +-- request 2 (turn) -- +03:custom_tool_call/exec:text((await tools.mcp__codex_apps__calendar__lookup({})).content[0].text); +04:custom_tool_call_output:Script completed + Wall time seconds + Output: + | calendar-lookup-ok diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index abe7ea4c60..8049043af4 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -6179,6 +6179,12 @@ class Tool(BaseModel): title: str | None = None +class ToolExposureSurface(Enum): + code_mode = "code_mode" + deferred = "deferred" + direct = "direct" + + class TurnDiffUpdatedNotification(BaseModel): model_config = ConfigDict( populate_by_name=True, @@ -6587,6 +6593,10 @@ class AppConfig(BaseModel): links: Annotated[ AppLinksConfig | None, Field(description="Per-account approval settings keyed by link ID.") ] = None + omit_tools_from: Annotated[ + list[ToolExposureSurface] | None, + Field(description="Additional model-facing surfaces omitted for this connector's tools."), + ] = None open_world_enabled: bool | None = None tools: AppToolsConfig | None = None