mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
192481d1a1
## Why We've added support for auth elicitation behind the auth_elicitation flag, but servers need to explicitly check the capability before it decides to send elicitations in order to be backward compatible. This PR adds the capability advertising conditioned on the flag. ## What changed - Build `client_elicitation_capability` from the `AuthElicitation` feature state. - Thread that capability through MCP config, session startup, and `McpConnectionManager` so RMCP initialization advertises the correct elicitation support. - Advertise both `form` and `url` elicitation when the feature is enabled, and preserve the empty default capability when it is disabled. - Add coverage for the feature-derived config shape and the advertised initialization payload. ## Testing - `cargo test -p codex-mcp` - `cargo test -p codex-core to_mcp_config_preserves_auth_elicitation_feature_from_config` - `cargo test -p codex-core` *(currently fails outside this change in `tools::handlers::multi_agents::tests::tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtrees_closed` with a stack overflow after unrelated tests have started running)*
349 lines
12 KiB
Rust
349 lines
12 KiB
Rust
use super::*;
|
|
use codex_config::Constrained;
|
|
use codex_config::types::AppToolApproval;
|
|
use codex_config::types::ApprovalsReviewer;
|
|
use codex_login::CodexAuth;
|
|
use codex_plugin::AppConnectorId;
|
|
use codex_plugin::PluginCapabilitySummary;
|
|
use codex_protocol::models::ManagedFileSystemPermissions;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::GranularApprovalConfig;
|
|
use pretty_assertions::assert_eq;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
|
|
McpConfig {
|
|
chatgpt_base_url: "https://chatgpt.com".to_string(),
|
|
apps_mcp_path_override: None,
|
|
codex_home,
|
|
mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(),
|
|
mcp_oauth_callback_port: None,
|
|
mcp_oauth_callback_url: None,
|
|
skill_mcp_dependency_install_enabled: true,
|
|
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
|
|
codex_linux_sandbox_exe: None,
|
|
use_legacy_landlock: false,
|
|
apps_enabled: false,
|
|
client_elicitation_capability: ElicitationCapability::default(),
|
|
configured_mcp_servers: HashMap::new(),
|
|
plugin_capability_summaries: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() {
|
|
assert_eq!(
|
|
qualified_mcp_tool_name_prefix("Some-Server"),
|
|
"mcp__Some_Server__".to_string()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_prompt_auto_approval_honors_unrestricted_managed_profiles() {
|
|
assert!(mcp_permission_prompt_is_auto_approved(
|
|
AskForApproval::Never,
|
|
&PermissionProfile::Managed {
|
|
file_system: ManagedFileSystemPermissions::Unrestricted,
|
|
network: NetworkSandboxPolicy::Enabled,
|
|
},
|
|
McpPermissionPromptAutoApproveContext::default(),
|
|
));
|
|
assert!(mcp_permission_prompt_is_auto_approved(
|
|
AskForApproval::Never,
|
|
&PermissionProfile::Managed {
|
|
file_system: ManagedFileSystemPermissions::Unrestricted,
|
|
network: NetworkSandboxPolicy::Restricted,
|
|
},
|
|
McpPermissionPromptAutoApproveContext::default(),
|
|
));
|
|
assert!(!mcp_permission_prompt_is_auto_approved(
|
|
AskForApproval::Never,
|
|
&PermissionProfile::read_only(),
|
|
McpPermissionPromptAutoApproveContext::default(),
|
|
));
|
|
assert!(!mcp_permission_prompt_is_auto_approved(
|
|
AskForApproval::OnRequest,
|
|
&PermissionProfile::Managed {
|
|
file_system: ManagedFileSystemPermissions::Unrestricted,
|
|
network: NetworkSandboxPolicy::Enabled,
|
|
},
|
|
McpPermissionPromptAutoApproveContext::default(),
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_prompt_auto_approval_honors_approved_tools_in_all_permission_modes() {
|
|
for approval_policy in [
|
|
AskForApproval::UnlessTrusted,
|
|
AskForApproval::OnFailure,
|
|
AskForApproval::OnRequest,
|
|
AskForApproval::Granular(GranularApprovalConfig {
|
|
sandbox_approval: true,
|
|
rules: true,
|
|
skill_approval: true,
|
|
request_permissions: true,
|
|
mcp_elicitations: true,
|
|
}),
|
|
AskForApproval::Never,
|
|
] {
|
|
assert!(mcp_permission_prompt_is_auto_approved(
|
|
approval_policy,
|
|
&PermissionProfile::read_only(),
|
|
McpPermissionPromptAutoApproveContext {
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
tool_approval_mode: Some(AppToolApproval::Approve),
|
|
},
|
|
));
|
|
}
|
|
|
|
assert!(!mcp_permission_prompt_is_auto_approved(
|
|
AskForApproval::OnRequest,
|
|
&PermissionProfile::read_only(),
|
|
McpPermissionPromptAutoApproveContext {
|
|
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
|
tool_approval_mode: Some(AppToolApproval::Auto),
|
|
},
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() {
|
|
assert!(!mcp_permission_prompt_is_auto_approved(
|
|
AskForApproval::OnRequest,
|
|
&PermissionProfile::read_only(),
|
|
McpPermissionPromptAutoApproveContext {
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
tool_approval_mode: Some(AppToolApproval::Auto),
|
|
},
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_plugin_provenance_collects_app_and_mcp_sources() {
|
|
let provenance = ToolPluginProvenance::from_capability_summaries(&[
|
|
PluginCapabilitySummary {
|
|
display_name: "alpha-plugin".to_string(),
|
|
app_connector_ids: vec![AppConnectorId("connector_example".to_string())],
|
|
mcp_server_names: vec!["alpha".to_string()],
|
|
..PluginCapabilitySummary::default()
|
|
},
|
|
PluginCapabilitySummary {
|
|
display_name: "beta-plugin".to_string(),
|
|
app_connector_ids: vec![
|
|
AppConnectorId("connector_example".to_string()),
|
|
AppConnectorId("connector_gmail".to_string()),
|
|
],
|
|
mcp_server_names: vec!["beta".to_string()],
|
|
..PluginCapabilitySummary::default()
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
provenance,
|
|
ToolPluginProvenance {
|
|
plugin_display_names_by_connector_id: HashMap::from([
|
|
(
|
|
"connector_example".to_string(),
|
|
vec!["alpha-plugin".to_string(), "beta-plugin".to_string()],
|
|
),
|
|
(
|
|
"connector_gmail".to_string(),
|
|
vec!["beta-plugin".to_string()],
|
|
),
|
|
]),
|
|
plugin_display_names_by_mcp_server_name: HashMap::from([
|
|
("alpha".to_string(), vec!["alpha-plugin".to_string()]),
|
|
("beta".to_string(), vec!["beta-plugin".to_string()]),
|
|
]),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() {
|
|
assert_eq!(
|
|
codex_apps_mcp_url_for_base_url(
|
|
"https://chatgpt.com/backend-api",
|
|
/*apps_mcp_path_override*/ None,
|
|
),
|
|
"https://chatgpt.com/backend-api/wham/apps"
|
|
);
|
|
assert_eq!(
|
|
codex_apps_mcp_url_for_base_url(
|
|
"https://chat.openai.com",
|
|
/*apps_mcp_path_override*/ None,
|
|
),
|
|
"https://chat.openai.com/backend-api/wham/apps"
|
|
);
|
|
assert_eq!(
|
|
codex_apps_mcp_url_for_base_url(
|
|
"http://localhost:8080/api/codex",
|
|
/*apps_mcp_path_override*/ None,
|
|
),
|
|
"http://localhost:8080/api/codex/apps"
|
|
);
|
|
assert_eq!(
|
|
codex_apps_mcp_url_for_base_url(
|
|
"http://localhost:8080",
|
|
/*apps_mcp_path_override*/ None,
|
|
),
|
|
"http://localhost:8080/api/codex/apps"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_mcp_url_uses_legacy_codex_apps_path() {
|
|
let config = test_mcp_config(PathBuf::from("/tmp"));
|
|
|
|
assert_eq!(
|
|
codex_apps_mcp_url(&config),
|
|
"https://chatgpt.com/backend-api/wham/apps"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_server_config_uses_legacy_codex_apps_path() {
|
|
let mut config = test_mcp_config(PathBuf::from("/tmp"));
|
|
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
|
|
|
let mut servers = with_codex_apps_mcp(HashMap::new(), /*auth*/ None, &config);
|
|
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
|
|
|
|
config.apps_enabled = true;
|
|
|
|
servers = with_codex_apps_mcp(servers, Some(&auth), &config);
|
|
let server = servers
|
|
.get(CODEX_APPS_MCP_SERVER_NAME)
|
|
.expect("codex apps should be present when apps is enabled");
|
|
let config = server
|
|
.configured_config()
|
|
.expect("codex apps should use configured transport");
|
|
let url = match &config.transport {
|
|
McpServerTransportConfig::StreamableHttp { url, .. } => url,
|
|
_ => panic!("expected streamable http transport for codex apps"),
|
|
};
|
|
|
|
assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps");
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_server_config_uses_configured_apps_mcp_path_override() {
|
|
let mut config = test_mcp_config(PathBuf::from("/tmp"));
|
|
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
|
|
config.apps_enabled = true;
|
|
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
|
|
|
let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config);
|
|
let server = servers
|
|
.get(CODEX_APPS_MCP_SERVER_NAME)
|
|
.expect("codex apps should be present when apps is enabled");
|
|
let config = server
|
|
.configured_config()
|
|
.expect("codex apps should use configured transport");
|
|
let url = match &config.transport {
|
|
McpServerTransportConfig::StreamableHttp { url, .. } => url,
|
|
_ => panic!("expected streamable http transport for codex apps"),
|
|
};
|
|
|
|
assert_eq!(url, "https://chatgpt.com/backend-api/custom/mcp");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
|
|
let codex_home = tempfile::tempdir().expect("tempdir");
|
|
let mut config = test_mcp_config(codex_home.path().to_path_buf());
|
|
config.apps_enabled = true;
|
|
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
|
|
|
config.configured_mcp_servers.insert(
|
|
"sample".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url: "https://user.example/mcp".to_string(),
|
|
bearer_token_env_var: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
);
|
|
config.configured_mcp_servers.insert(
|
|
"docs".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url: "https://docs.example/mcp".to_string(),
|
|
bearer_token_env_var: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
);
|
|
|
|
let effective = effective_mcp_servers(&config, Some(&auth));
|
|
|
|
let sample = effective.get("sample").expect("user server should exist");
|
|
let docs = effective
|
|
.get("docs")
|
|
.expect("configured server should exist");
|
|
let codex_apps = effective
|
|
.get(CODEX_APPS_MCP_SERVER_NAME)
|
|
.expect("codex apps server should exist");
|
|
|
|
let sample = sample
|
|
.configured_config()
|
|
.expect("configured server should retain transport");
|
|
let docs = docs
|
|
.configured_config()
|
|
.expect("configured server should retain transport");
|
|
let codex_apps = codex_apps
|
|
.configured_config()
|
|
.expect("codex apps should use configured transport");
|
|
|
|
match &sample.transport {
|
|
McpServerTransportConfig::StreamableHttp { url, .. } => {
|
|
assert_eq!(url, "https://user.example/mcp");
|
|
}
|
|
other => panic!("expected streamable http transport, got {other:?}"),
|
|
}
|
|
match &docs.transport {
|
|
McpServerTransportConfig::StreamableHttp { url, .. } => {
|
|
assert_eq!(url, "https://docs.example/mcp");
|
|
}
|
|
other => panic!("expected streamable http transport, got {other:?}"),
|
|
}
|
|
match &codex_apps.transport {
|
|
McpServerTransportConfig::StreamableHttp { url, .. } => {
|
|
assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps");
|
|
}
|
|
other => panic!("expected streamable http transport, got {other:?}"),
|
|
}
|
|
}
|