Support Codex Apps auth elicitations (#19193)

## Summary

- request URL-mode MCP elicitations when Codex Apps tool calls fail with
connector auth metadata
- route Codex Apps auth URL elicitations into the TUI app-link flow

## Test plan

- `just fmt`
- `cargo test -p codex-core mcp_tool_call::tests`
- `cargo test -p codex-mcp`
- `cargo test -p codex-tui bottom_pane::app_link_view::tests`
- `just fix -p codex-core`
- `just fix -p codex-mcp`
- `just fix -p codex-tui`

Also attempted broader local runs:

- `cargo test -p codex-core` fails in unrelated
config/request-permission/proxy-sensitive tests under the current Codex
Desktop environment.
- `cargo test -p codex-tui` fails in unrelated status
snapshots/trust-default tests because the ambient environment renders
workspace-write/network permission defaults.
This commit is contained in:
Matthew Zeng
2026-05-06 00:18:00 -07:00
committed by GitHub
Unverified
parent 22326e263c
commit f9a907aebe
25 changed files with 1748 additions and 78 deletions
+3
View File
@@ -45,6 +45,7 @@ use codex_mcp::ToolInfo;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::codex_apps_tools_cache_key;
use codex_mcp::compute_auth_statuses;
use codex_mcp::host_owned_codex_apps_enabled;
use codex_mcp::with_codex_apps_mcp;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
@@ -246,6 +247,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
let mcp_config = config.to_mcp_config(plugins_manager.as_ref()).await;
let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config);
let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
if mcp_servers.is_empty() {
return Ok(AccessibleConnectorsStatus {
connectors: Vec::new(),
@@ -278,6 +280,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()),
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
ToolPluginProvenance::default(),
auth.as_ref(),
)
+132 -11
View File
@@ -41,8 +41,11 @@ use codex_config::types::AppToolApproval;
use codex_features::Feature;
use codex_hooks::PermissionRequestDecision;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY;
use codex_mcp::McpPermissionPromptAutoApproveContext;
use codex_mcp::SandboxState;
use codex_mcp::auth_elicitation_completed_result;
use codex_mcp::build_auth_elicitation_plan;
use codex_mcp::declared_openai_file_input_param_names;
use codex_mcp::mcp_permission_prompt_is_auto_approved;
use codex_otel::sanitize_metric_tag_value;
@@ -52,6 +55,7 @@ use codex_protocol::items::McpToolCallStatus;
use codex_protocol::items::TurnItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::openai_models::InputModality;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::request_user_input::RequestUserInputAnswer;
@@ -340,9 +344,10 @@ async fn handle_approved_mcp_tool_call(
let result = execute_mcp_tool_call(
sess,
turn_context,
&server,
&tool_name,
call_id,
&invocation,
rewritten_arguments,
metadata,
request_meta,
)
.await;
@@ -541,28 +546,145 @@ fn truncate_str_to_char_boundary(value: &str, max_chars: usize) -> &str {
async fn execute_mcp_tool_call(
sess: &Session,
turn_context: &TurnContext,
server: &str,
tool_name: &str,
call_id: &str,
invocation: &McpInvocation,
rewritten_arguments: Option<JsonValue>,
metadata: Option<&McpToolApprovalMetadata>,
request_meta: Option<JsonValue>,
) -> Result<CallToolResult, String> {
let request_meta =
with_mcp_tool_call_thread_id_meta(request_meta, &sess.conversation_id.to_string());
let request_meta =
augment_mcp_tool_request_meta_with_sandbox_state(sess, turn_context, server, request_meta)
.await
.map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?;
let request_meta = augment_mcp_tool_request_meta_with_sandbox_state(
sess,
turn_context,
&invocation.server,
request_meta,
)
.await
.map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?;
let result = sess
.call_tool(server, tool_name, rewritten_arguments, request_meta)
.call_tool(
&invocation.server,
&invocation.tool,
rewritten_arguments,
request_meta,
)
.await
.map_err(|e| format!("tool call error: {e:?}"))?;
sanitize_mcp_tool_result_for_model(
let result = sanitize_mcp_tool_result_for_model(
turn_context
.model_info
.input_modalities
.contains(&InputModality::Image),
Ok(result),
)?;
Ok(maybe_request_codex_apps_auth_elicitation(
sess,
turn_context,
call_id,
&invocation.server,
metadata,
result,
)
.await)
}
async fn maybe_request_codex_apps_auth_elicitation(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
server: &str,
metadata: Option<&McpToolApprovalMetadata>,
result: CallToolResult,
) -> CallToolResult {
if !sess
.services
.mcp_connection_manager
.read()
.await
.is_host_owned_codex_apps_server(server)
{
return result;
}
if !turn_context.features.enabled(Feature::AuthElicitation) {
return result;
}
match turn_context.approval_policy.value() {
AskForApproval::Never => return result,
AskForApproval::Granular(granular_config) if !granular_config.allows_mcp_elicitations() => {
return result;
}
AskForApproval::OnFailure
| AskForApproval::OnRequest
| AskForApproval::UnlessTrusted
| AskForApproval::Granular(_) => {}
}
let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref());
let connector_name = metadata.and_then(|metadata| metadata.connector_name.as_deref());
let install_url = connector_id.map(|connector_id| {
codex_connectors::metadata::connector_install_url(
connector_name.unwrap_or(connector_id),
connector_id,
)
});
let Some(plan) =
build_auth_elicitation_plan(call_id, &result, connector_id, connector_name, install_url)
else {
return result;
};
let request_id = rmcp::model::RequestId::String(plan.elicitation.elicitation_id.clone().into());
let params = McpServerElicitationRequestParams {
thread_id: sess.conversation_id.to_string(),
turn_id: Some(turn_context.sub_id.clone()),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Url {
meta: Some(plan.elicitation.meta),
message: plan.elicitation.message,
url: plan.elicitation.url,
elicitation_id: plan.elicitation.elicitation_id,
},
};
let response = sess
.request_mcp_server_elicitation(turn_context, request_id, params)
.await;
if !response
.as_ref()
.is_some_and(|response| response.action == ElicitationAction::Accept)
{
return result;
}
refresh_codex_apps_after_connector_auth(sess, turn_context).await;
auth_elicitation_completed_result(&plan.auth_failure, result.meta)
}
#[expect(
clippy::await_holding_invalid_type,
reason = "Codex Apps cache refresh reads through the session-owned manager guard"
)]
async fn refresh_codex_apps_after_connector_auth(sess: &Session, turn_context: &TurnContext) {
let mcp_tools_result = {
let manager = sess.services.mcp_connection_manager.read().await;
manager.hard_refresh_codex_apps_tools_cache().await
};
match mcp_tools_result {
Ok(mcp_tools) => {
let auth = sess.services.auth_manager.auth().await;
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
&turn_context.config,
auth.as_ref(),
&mcp_tools,
);
}
Err(err) => {
tracing::warn!("failed to refresh Codex Apps tools after connector auth: {err:#}");
}
}
}
#[expect(
@@ -834,7 +956,6 @@ pub(crate) struct McpToolApprovalMetadata {
openai_file_input_params: Option<Vec<String>>,
}
const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
+247
View File
@@ -1,5 +1,6 @@
use super::*;
use crate::config::ConfigBuilder;
use crate::config::ManagedFeatures;
use crate::session::tests::make_session_and_context;
use crate::session::tests::make_session_and_context_with_rx;
use crate::state::ActiveTurn;
@@ -14,11 +15,13 @@ use codex_config::types::ApprovalsReviewer;
use codex_config::types::AppsConfigToml;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerToolConfig;
use codex_features::Features;
use codex_hooks::Hooks;
use codex_hooks::HooksConfig;
use codex_model_provider::create_model_provider;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GranularApprovalConfig;
use core_test_support::PathExt;
use core_test_support::hooks::trusted_config_layer_stack;
@@ -1065,6 +1068,250 @@ async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_cod
);
}
fn codex_apps_auth_failure_result() -> CallToolResult {
CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "Connector reauthentication required",
})],
structured_content: None,
is_error: Some(true),
meta: Some(serde_json::json!({
MCP_TOOL_CODEX_APPS_META_KEY: {
"connector_auth_failure": {
"is_auth_failure": true,
"auth_reason": "reauthentication_required",
"connector_id": "connector_calendar",
"connector_name": "Untrusted Calendar",
"link_id": "link_123",
"error_code": "UNAUTHORIZED",
"error_http_status_code": 401,
"error_action": "TRIGGER_REAUTHENTICATION",
},
},
})),
}
}
fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata {
approval_metadata(
Some("connector_calendar"),
Some("Google Calendar"),
Some("Manage events and schedules."),
Some("Create Event"),
Some("Create a calendar event."),
)
}
async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) {
let auth = session.services.auth_manager.auth().await;
let environment = session
.services
.environment_manager
.default_environment()
.unwrap_or_else(|| session.services.environment_manager.local_environment());
let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new(
&HashMap::new(),
turn_context.config.mcp_oauth_credentials_store_mode,
HashMap::new(),
&turn_context.approval_policy,
turn_context.sub_id.clone(),
session.get_tx_event(),
turn_context.permission_profile(),
codex_mcp::McpRuntimeEnvironment::new(environment, turn_context.cwd.to_path_buf()),
turn_context.config.codex_home.to_path_buf(),
codex_mcp::codex_apps_tools_cache_key(auth.as_ref()),
/*host_owned_codex_apps_enabled*/ true,
codex_mcp::ToolPluginProvenance::default(),
auth.as_ref(),
)
.await;
*session.services.mcp_connection_manager.write().await = manager;
}
#[tokio::test]
async fn codex_apps_auth_elicitation_feature_disabled_returns_original_result() {
let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;
install_host_owned_codex_apps_manager(&session, &turn_context).await;
let result = codex_apps_auth_failure_result();
let metadata = codex_apps_auth_failure_metadata();
let returned = maybe_request_codex_apps_auth_elicitation(
&session,
&turn_context,
"call_123",
CODEX_APPS_MCP_SERVER_NAME,
Some(&metadata),
result.clone(),
)
.await;
assert_eq!(returned, result);
assert!(rx_event.try_recv().is_err());
}
#[tokio::test]
async fn codex_apps_auth_elicitation_non_host_owned_server_returns_original_result() {
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
let mut features = Features::with_defaults();
features.enable(Feature::AuthElicitation);
Arc::get_mut(&mut turn_context)
.expect("single turn context ref")
.features = ManagedFeatures::from(features);
let result = codex_apps_auth_failure_result();
let metadata = codex_apps_auth_failure_metadata();
let returned = maybe_request_codex_apps_auth_elicitation(
&session,
&turn_context,
"call_123",
CODEX_APPS_MCP_SERVER_NAME,
Some(&metadata),
result.clone(),
)
.await;
assert_eq!(returned, result);
assert!(rx_event.try_recv().is_err());
}
#[tokio::test]
async fn codex_apps_auth_elicitation_disallowed_by_policy_returns_original_result() {
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
install_host_owned_codex_apps_manager(&session, &turn_context).await;
let mut features = Features::with_defaults();
features.enable(Feature::AuthElicitation);
let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
turn_context.features = ManagedFeatures::from(features);
turn_context
.approval_policy
.set(AskForApproval::Never)
.expect("test setup should allow updating approval policy");
let result = codex_apps_auth_failure_result();
let metadata = codex_apps_auth_failure_metadata();
let returned = maybe_request_codex_apps_auth_elicitation(
&session,
turn_context,
"call_123",
CODEX_APPS_MCP_SERVER_NAME,
Some(&metadata),
result.clone(),
)
.await;
assert_eq!(returned, result);
assert!(rx_event.try_recv().is_err());
}
#[tokio::test]
async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_result() {
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
install_host_owned_codex_apps_manager(&session, &turn_context).await;
let mut features = Features::with_defaults();
features.enable(Feature::AuthElicitation);
let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
turn_context.features = ManagedFeatures::from(features);
turn_context
.approval_policy
.set(AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: false,
}))
.expect("test setup should allow updating approval policy");
let result = codex_apps_auth_failure_result();
let metadata = codex_apps_auth_failure_metadata();
let returned = maybe_request_codex_apps_auth_elicitation(
&session,
turn_context,
"call_123",
CODEX_APPS_MCP_SERVER_NAME,
Some(&metadata),
result.clone(),
)
.await;
assert_eq!(returned, result);
assert!(rx_event.try_recv().is_err());
}
#[tokio::test]
async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() {
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
install_host_owned_codex_apps_manager(&session, &turn_context).await;
*session.active_turn.lock().await = Some(ActiveTurn::default());
let mut features = Features::with_defaults();
features.enable(Feature::AuthElicitation);
Arc::get_mut(&mut turn_context)
.expect("single turn context ref")
.features = ManagedFeatures::from(features);
let result = codex_apps_auth_failure_result();
let metadata = codex_apps_auth_failure_metadata();
let request_task = tokio::spawn({
let session = Arc::clone(&session);
let turn_context = Arc::clone(&turn_context);
async move {
maybe_request_codex_apps_auth_elicitation(
&session,
&turn_context,
"call_123",
CODEX_APPS_MCP_SERVER_NAME,
Some(&metadata),
result,
)
.await
}
});
let request = loop {
let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_event.recv())
.await
.expect("elicitation event timed out")
.expect("expected elicitation event");
if let EventMsg::ElicitationRequest(request) = event.msg {
break request;
}
};
assert_eq!(request.server_name, CODEX_APPS_MCP_SERVER_NAME);
assert_eq!(
request.id,
codex_protocol::mcp::RequestId::String("codex_apps_auth_call_123".to_string())
);
assert!(matches!(
request.request,
codex_protocol::approvals::ElicitationRequest::Url { .. }
));
session
.resolve_elicitation(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
rmcp::model::RequestId::String("codex_apps_auth_call_123".into()),
ElicitationResponse {
action: ElicitationAction::Accept,
content: None,
meta: None,
},
)
.await
.expect("elicitation should resolve");
let returned = tokio::time::timeout(std::time::Duration::from_secs(1), request_task)
.await
.expect("auth elicitation task timed out")
.expect("auth elicitation task failed");
assert_eq!(
returned.content,
vec![serde_json::json!({
"type": "text",
"text": "Authentication for Google Calendar was requested and accepted. Retry this tool call now.",
})]
);
}
#[test]
fn mcp_tool_call_thread_id_meta_is_added_to_request_meta() {
assert_eq!(
+3
View File
@@ -233,6 +233,8 @@ impl Session {
.tool_plugin_provenance(config.as_ref())
.await;
let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config);
let host_owned_codex_apps_enabled =
host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
let auth_statuses =
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
let mcp_runtime_environment = match turn_context.environments.primary() {
@@ -264,6 +266,7 @@ impl Session {
mcp_runtime_environment,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
tool_plugin_provenance,
auth.as_ref(),
)
+1
View File
@@ -307,6 +307,7 @@ use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_core_plugins::PluginsManager;
use codex_git_utils::get_git_repo_root;
use codex_mcp::compute_auth_statuses;
use codex_mcp::host_owned_codex_apps_enabled;
use codex_mcp::with_codex_apps_mcp;
use codex_otel::SessionTelemetry;
use codex_otel::THREAD_STARTED_METRIC;
+4
View File
@@ -918,6 +918,9 @@ impl Session {
let enabled_mcp_server_count = mcp_servers.values().filter(|server| server.enabled).count();
let required_mcp_server_count = required_mcp_servers.len();
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config.as_ref()).await;
let host_owned_codex_apps_enabled = config
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(|auth| auth.uses_codex_backend()));
{
let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await;
cancel_guard.cancel();
@@ -959,6 +962,7 @@ impl Session {
mcp_runtime_environment,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth),
host_owned_codex_apps_enabled,
tool_plugin_provenance,
auth,
)