mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Allow subagents to request MCP elicitation input (#46877)
## Why MCP requests requiring user input previously failed in subagents with guidance to ask the parent agent, blocking browser sign-in, form input, and interactive tool approval in the child thread. ## What changed Remove the root-only MCP elicitation guard and `allow_user_interaction` plumbing so subagents can surface prompts and wait for responses under the existing approval policy and automatic review rules. Connector authentication can now return retry guidance after acceptance or preserve the original failure when declined. Keep legacy tool approvals through `request_user_input` restricted to the root thread. ## Testing Update unit and integration coverage for subagent prompt resolution, form responses, connector authentication acceptance and decline, automatic approval, strict review, and approval-policy updates on reused connections. Update the browser sign-in scenario snapshot to show the subagent continuing after an accepted response. GitOrigin-RevId: c226fc6dfde724ed22b91dc04dd32bbc15245bee
This commit is contained in:
@@ -269,7 +269,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
client_mcp_extensions: ClientMcpExtensions::default(),
|
||||
auth: auth.clone(),
|
||||
auth_manager: codex_apps_auth_manager,
|
||||
allow_user_interaction: true,
|
||||
elicitation_reviewer: None,
|
||||
elicitation_lifecycle: None,
|
||||
})
|
||||
|
||||
@@ -781,39 +781,14 @@ async fn maybe_request_codex_apps_auth_elicitation(
|
||||
url: plan.elicitation.url,
|
||||
elicitation_id: plan.elicitation.elicitation_id,
|
||||
};
|
||||
let outcome = match sess
|
||||
let outcome = sess
|
||||
.request_mcp_server_elicitation(
|
||||
turn_context,
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
request_id,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => {
|
||||
let mut result = result;
|
||||
// Direct output prefers structured content. Preserve its diagnostic as
|
||||
// text so both output paths show it alongside the recovery guidance.
|
||||
if let Some(structured_content) = result.structured_content.take() {
|
||||
result.content.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": structured_content.to_string(),
|
||||
}));
|
||||
}
|
||||
result.content.insert(
|
||||
/*index*/ 0,
|
||||
serde_json::json!({
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"Authentication for {} could not be completed. {error}",
|
||||
plan.auth_failure.connector_name
|
||||
),
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
.await;
|
||||
if !outcome
|
||||
.response
|
||||
.as_ref()
|
||||
@@ -1622,10 +1597,6 @@ pub(crate) async fn request_mcp_tool_user_approval(
|
||||
);
|
||||
}
|
||||
|
||||
if turn_context.session_source.is_non_root_agent() {
|
||||
return ReviewDecision::denied(codex_mcp::MCP_ELICITATION_HANDOFF_MESSAGE);
|
||||
}
|
||||
|
||||
let tool_call_mcp_elicitation_enabled = turn_context
|
||||
.config
|
||||
.features
|
||||
@@ -1691,18 +1662,21 @@ pub(crate) async fn request_mcp_tool_user_approval(
|
||||
.map(|rendered_template| rendered_template.elicitation_message.as_str()),
|
||||
prompt_options,
|
||||
});
|
||||
let outcome = match sess
|
||||
let outcome = sess
|
||||
.request_mcp_server_elicitation(turn_context, server.clone(), request_id, request)
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => return ReviewDecision::denied(error.to_string()),
|
||||
};
|
||||
.await;
|
||||
(
|
||||
outcome.sent,
|
||||
parse_mcp_tool_approval_elicitation_response(outcome.response, &question_id),
|
||||
)
|
||||
} else {
|
||||
if turn_context.session_source.is_non_root_agent() {
|
||||
return ReviewDecision::denied(concat!(
|
||||
"MCP tool approval via request_user_input requires the root thread. ",
|
||||
"Ask the parent agent to handle this request. ",
|
||||
"Do not retry the blocked action until the parent confirms the blocker is resolved."
|
||||
));
|
||||
}
|
||||
let args = RequestUserInputArgs {
|
||||
questions: vec![question],
|
||||
is_blocking: true,
|
||||
|
||||
@@ -13,9 +13,6 @@ use crate::session::tests::update_turn_settings_for_test;
|
||||
use crate::session::turn_context::TurnEnvironment;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::test_support::models_manager_with_provider;
|
||||
use crate::tools::context::McpToolOutput;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::hook_names::HookToolName;
|
||||
use crate::turn_metadata::ExecutionMetadata;
|
||||
use codex_app_server_protocol as app_server_protocol;
|
||||
@@ -33,7 +30,6 @@ use codex_hooks::HooksConfig;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_protocol::ResponseItemId;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EnvironmentConfig;
|
||||
@@ -1725,91 +1721,17 @@ async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_resu
|
||||
assert!(rx_event.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test_case::test_case(SessionSource::Exec; "root")]
|
||||
#[test_case::test_case(SessionSource::SubAgent(SubAgentSource::Review); "subagent")]
|
||||
#[test_case::test_case(SessionSource::Internal(InternalSessionSource::Guardian); "guardian")]
|
||||
#[tokio::test]
|
||||
async fn codex_apps_auth_elicitation_returns_subagent_handoff_and_diagnostics() {
|
||||
async fn codex_apps_auth_elicitation_enabled_by_default_requests_elicitation(
|
||||
source: SessionSource,
|
||||
) {
|
||||
let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
Arc::get_mut(&mut turn_context)
|
||||
.expect("single turn context ref")
|
||||
.session_source = SessionSource::SubAgent(SubAgentSource::Review);
|
||||
let structured_content = serde_json::json!({
|
||||
"error": "reauthentication_required", "status": 401
|
||||
});
|
||||
let mut result = codex_apps_auth_failure_result();
|
||||
result.structured_content = Some(structured_content.clone());
|
||||
let diagnostic = format!(
|
||||
"Connector reauthentication required: {}",
|
||||
"diagnostic detail ".repeat(/*n*/ 500)
|
||||
);
|
||||
result.content[0]["text"] = serde_json::json!(diagnostic);
|
||||
let metadata = codex_apps_auth_failure_metadata();
|
||||
let returned = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
maybe_request_codex_apps_auth_elicitation(
|
||||
&session,
|
||||
&turn_context,
|
||||
turn_context.approval_policy(),
|
||||
"call_123",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
Some(&metadata),
|
||||
result.clone(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("subagent auth must not wait for user input");
|
||||
|
||||
assert_eq!(returned.meta, result.meta);
|
||||
assert_eq!(returned.is_error, Some(true));
|
||||
assert!(rx_event.try_recv().is_err());
|
||||
|
||||
let output = McpToolOutput {
|
||||
result: returned,
|
||||
tool_input: serde_json::json!({}),
|
||||
result_metadata_capture_allowed: true,
|
||||
wall_time: std::time::Duration::ZERO,
|
||||
original_image_detail_supported: false,
|
||||
truncation_policy: TruncationPolicy::Tokens(256),
|
||||
};
|
||||
let payload = ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
};
|
||||
let code_mode = output.code_mode_result(&payload);
|
||||
assert_eq!(code_mode["isError"], serde_json::json!(true));
|
||||
assert!(code_mode.get("_meta").is_none());
|
||||
let code_text = code_mode["content"]
|
||||
.as_array()
|
||||
.expect("MCP content")
|
||||
.iter()
|
||||
.map(|item| item["text"].as_str().expect("auth diagnostic text"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(code_text.contains("Authentication for Google Calendar could not be completed."));
|
||||
assert!(code_text.contains(codex_mcp::MCP_ELICITATION_HANDOFF_MESSAGE));
|
||||
assert!(code_text.contains(&diagnostic));
|
||||
assert!(code_text.contains(&structured_content.to_string()));
|
||||
assert_eq!(output.tool_result_metadata(), result.meta.as_ref());
|
||||
let mut expected_hook = code_mode;
|
||||
expected_hook["_meta"] = result.meta.expect("original auth metadata");
|
||||
assert_eq!(
|
||||
output.post_tool_use_response("call_123", &payload),
|
||||
Some(expected_hook)
|
||||
);
|
||||
|
||||
let ResponseInputItem::FunctionCallOutput {
|
||||
output: truncated, ..
|
||||
} = output.to_response_item("call_123", &payload)
|
||||
else {
|
||||
panic!("expected FunctionCallOutput");
|
||||
};
|
||||
let truncated_text = truncated.body.to_text().expect("truncated auth diagnostic");
|
||||
assert!(truncated_text.contains(codex_mcp::MCP_ELICITATION_HANDOFF_MESSAGE));
|
||||
assert!(truncated_text.contains("truncated"));
|
||||
assert!(!truncated_text.contains(&diagnostic));
|
||||
assert_eq!(truncated.success, Some(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_apps_auth_elicitation_enabled_by_default_requests_elicitation() {
|
||||
let (session, turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
.session_source = source;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let result = codex_apps_auth_failure_result();
|
||||
let metadata = codex_apps_auth_failure_metadata();
|
||||
|
||||
@@ -554,20 +554,16 @@ impl Session {
|
||||
server_name: String,
|
||||
request_id: RequestId,
|
||||
request: ElicitationRequest,
|
||||
) -> anyhow::Result<McpServerElicitationOutcome> {
|
||||
anyhow::ensure!(
|
||||
!turn_context.session_source.is_non_root_agent(),
|
||||
codex_mcp::MCP_ELICITATION_HANDOFF_MESSAGE
|
||||
);
|
||||
) -> McpServerElicitationOutcome {
|
||||
if self.services.mcp_runtime.elicitations_auto_deny() {
|
||||
return Ok(McpServerElicitationOutcome {
|
||||
return McpServerElicitationOutcome {
|
||||
response: Some(ElicitationResponse {
|
||||
action: codex_rmcp_client::ElicitationAction::Accept,
|
||||
content: Some(serde_json::json!({})),
|
||||
meta: None,
|
||||
}),
|
||||
sent: false,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
let _elicitation = self.services.elicitations.register();
|
||||
@@ -619,10 +615,10 @@ impl Session {
|
||||
plugin_install_telemetry.tool_name.as_str(),
|
||||
);
|
||||
}
|
||||
Ok(McpServerElicitationOutcome {
|
||||
McpServerElicitationOutcome {
|
||||
response: rx_response.await.ok(),
|
||||
sent: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
|
||||
@@ -374,7 +374,6 @@ impl Session {
|
||||
client_mcp_extensions: self.services.client_mcp_extensions.for_mcp_servers(),
|
||||
auth,
|
||||
auth_manager: Some(Arc::clone(&self.services.auth_manager)),
|
||||
allow_user_interaction: !desired.session_source.is_non_root_agent(),
|
||||
elicitation_reviewer,
|
||||
elicitation_lifecycle: Some(self.mcp_elicitation_lifecycle()),
|
||||
}
|
||||
|
||||
@@ -638,9 +638,17 @@ async fn regular_turn_emits_turn_started_with_trace_id_without_waiting_for_start
|
||||
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
}
|
||||
|
||||
#[test_case(SessionSource::Exec; "root")]
|
||||
#[test_case(SessionSource::SubAgent(SubAgentSource::Review); "subagent")]
|
||||
#[test_case(SessionSource::Internal(InternalSessionSource::Guardian); "guardian")]
|
||||
#[tokio::test]
|
||||
async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled() {
|
||||
let (session, turn_context, rx) = make_session_and_context_with_rx().await;
|
||||
async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled(
|
||||
source: SessionSource,
|
||||
) {
|
||||
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
|
||||
Arc::get_mut(&mut turn_context)
|
||||
.expect("turn context should not be shared")
|
||||
.session_source = source;
|
||||
session
|
||||
.services
|
||||
.mcp_runtime
|
||||
@@ -660,8 +668,7 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled()
|
||||
}),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("root thread elicitation should be accepted");
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
response.response,
|
||||
@@ -675,56 +682,78 @@ async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled()
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test_case(false; "interactive")]
|
||||
#[test_case(true; "auto_accept")]
|
||||
#[test_case(SessionSource::Exec; "root")]
|
||||
#[test_case(SessionSource::SubAgent(SubAgentSource::Review); "subagent")]
|
||||
#[test_case(SessionSource::Internal(InternalSessionSource::Guardian); "guardian")]
|
||||
#[tokio::test]
|
||||
async fn request_mcp_server_elicitation_rejects_non_root_threads(auto_deny: bool) {
|
||||
for source in [
|
||||
SessionSource::SubAgent(SubAgentSource::Review),
|
||||
SessionSource::Internal(InternalSessionSource::Guardian),
|
||||
] {
|
||||
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
|
||||
Arc::get_mut(&mut turn_context)
|
||||
.expect("turn context should not be shared")
|
||||
.session_source = source;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
session
|
||||
.services
|
||||
.mcp_runtime
|
||||
.set_elicitations_auto_deny(auto_deny);
|
||||
let paused = session.subscribe_elicitation_pause_state();
|
||||
async fn request_mcp_server_elicitation_waits_for_user_response(source: SessionSource) {
|
||||
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
|
||||
Arc::get_mut(&mut turn_context)
|
||||
.expect("turn context should not be shared")
|
||||
.session_source = source;
|
||||
*session.active_turn.lock().await = Some(ActiveTurn::default());
|
||||
let paused = session.subscribe_elicitation_pause_state();
|
||||
let request = ElicitationRequest::Url {
|
||||
meta: None,
|
||||
message: "Connect this app to continue.".to_string(),
|
||||
url: "https://example.com/connect".to_string(),
|
||||
elicitation_id: "connect-1".to_string(),
|
||||
};
|
||||
let pending = tokio::spawn({
|
||||
let session = Arc::clone(&session);
|
||||
let turn_context = Arc::clone(&turn_context);
|
||||
let request = request.clone();
|
||||
async move {
|
||||
session
|
||||
.request_mcp_server_elicitation(
|
||||
&turn_context,
|
||||
"codex_apps".to_string(),
|
||||
RequestId::String("request-1".into()),
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.expect("elicitation event should arrive")
|
||||
.expect("event channel should remain open");
|
||||
let EventMsg::ElicitationRequest(event) = event.msg else {
|
||||
panic!("expected MCP elicitation");
|
||||
};
|
||||
assert_eq!(
|
||||
event,
|
||||
codex_protocol::approvals::ElicitationRequestEvent {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
server_name: "codex_apps".to_string(),
|
||||
id: codex_protocol::mcp::RequestId::String("request-1".to_string()),
|
||||
request,
|
||||
}
|
||||
);
|
||||
assert!(*paused.borrow());
|
||||
assert!(!pending.is_finished());
|
||||
|
||||
let Err(error) = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
session.request_mcp_server_elicitation(
|
||||
turn_context.as_ref(),
|
||||
"codex_apps".to_string(),
|
||||
RequestId::String("request-1".into()),
|
||||
ElicitationRequest::Url {
|
||||
meta: None,
|
||||
message: "Connect this app to continue.".to_string(),
|
||||
url: "https://example.com/connect".to_string(),
|
||||
elicitation_id: "connect-1".to_string(),
|
||||
},
|
||||
),
|
||||
let response = ElicitationResponse {
|
||||
action: ElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: None,
|
||||
};
|
||||
session
|
||||
.resolve_elicitation(
|
||||
"codex_apps".to_string(),
|
||||
RequestId::String("request-1".into()),
|
||||
response.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("non-root elicitation must not wait for user input") else {
|
||||
panic!("non-root elicitation must be rejected");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
codex_mcp::MCP_ELICITATION_HANDOFF_MESSAGE
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert!(!*paused.borrow());
|
||||
assert!(
|
||||
!paused
|
||||
.has_changed()
|
||||
.expect("elicitation service should remain available")
|
||||
);
|
||||
}
|
||||
.expect("user response should resolve the elicitation");
|
||||
let outcome = tokio::time::timeout(Duration::from_secs(1), pending)
|
||||
.await
|
||||
.expect("elicitation should finish")
|
||||
.expect("elicitation task should succeed");
|
||||
assert_eq!(outcome.response, Some(response));
|
||||
assert!(outcome.sent);
|
||||
assert!(!*paused.borrow());
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -254,8 +254,7 @@ impl RequestPluginInstallHandler {
|
||||
request_id,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| FunctionCallError::RespondToModel(error.to_string()))?;
|
||||
.await;
|
||||
let response = elicitation.response;
|
||||
if let Some(response) = response.as_ref() {
|
||||
maybe_persist_disabled_install_request(&session, &turn, &tool, response).await;
|
||||
|
||||
Reference in New Issue
Block a user