mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Add per-app tool exposure configuration (#46035)
## Why Server-level tool exposure settings apply to every connector on the apps server. Individual connectors need a way to opt out of deferred discovery without changing exposure for other apps. ## What changed Add `apps.<connector_id>.omit_tools_from` with support for `code_mode`, `deferred`, and `direct`. Combine connector omissions with server omissions so app settings preserve server restrictions. For example, `omit_tools_from = ["deferred"]` makes a connector's tools available without tool search, through direct calls or Code Mode as permitted by the active tool mode and remaining restrictions. Expose the setting in the app-server protocol, JSON schemas, and generated TypeScript and Python types. ## Testing Add integration coverage for connector-specific exposure and MCP dispatch across tool modes, server restrictions, and direct-only namespaces. Extend config tests to cover populated, absent, and empty omission lists, and add a Code Mode request-history snapshot. GitOrigin-RevId: cc99ab290c8a878d06d8d7ccf2d1dbd7c46bfa8a
This commit is contained in:
+36
@@ -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": {
|
||||
|
||||
+36
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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<ToolExposureSurface> | 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.
|
||||
*/
|
||||
|
||||
@@ -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<Vec<ToolExposureSurface>>,
|
||||
pub approvals_reviewer: Option<ApprovalsReviewer>,
|
||||
pub destructive_enabled: Option<bool>,
|
||||
pub open_world_enabled: Option<bool>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Vec<ToolExposureSurface>>,
|
||||
|
||||
/// Reviewer for approval prompts from this app, overriding the thread default.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approvals_reviewer: Option<ApprovalsReviewer>,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<ToolExposures>()
|
||||
});
|
||||
|
||||
@@ -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<ToolExposureSurface>);
|
||||
|
||||
impl McpServerContributor<Config> for AppsServer {
|
||||
fn id(&self) -> &'static str {
|
||||
"app_exposure_fixture"
|
||||
}
|
||||
|
||||
fn contribute<'a>(
|
||||
&'a self,
|
||||
context: McpServerContributionContext<'a, Config>,
|
||||
) -> ExtensionFuture<'a, Vec<McpServerContribution>> {
|
||||
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<Vec<responses::ResponsesRequest>> {
|
||||
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)
|
||||
}
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
+37
@@ -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_INSTRUCTIONS>
|
||||
model: "gpt-5.5"
|
||||
parallel_tool_calls: true
|
||||
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
|
||||
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] <PERMISSIONS_INSTRUCTIONS>
|
||||
[02] <APPS_INSTRUCTIONS>
|
||||
01:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
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 <DURATION> seconds
|
||||
Output:
|
||||
| calendar-lookup-ok
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user