mirror of
https://github.com/openai/codex.git
synced 2026-09-04 15:08:45 +00:00
## Why App-server clients need to advertise structured MCP extension settings, including supported MCP App UI MIME types, rather than only opting into OpenAI form elicitation with a boolean. ## What changed - Add an `extensions` map to initialize capabilities and preserve `mcpServerOpenaiFormElicitation` as a legacy alias for `openai/form`. - Capture the declared extension profile when a thread is started, resumed, or forked, propagate it to subagents, and advertise it to downstream MCP servers during initialization. - Keep the profile stable for the lifetime of the loaded session instead of changing it on later turns or direct tool calls. ## Testing - Cover extension conversion, downstream MCP initialization, session isolation, legacy form support, and subagent inheritance. GitOrigin-RevId: fbcedbb74ce788e574b0f884a4c45c4cedb9de54
954 lines
34 KiB
Rust
954 lines
34 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use async_channel::Receiver;
|
|
use async_channel::Sender;
|
|
use codex_analytics::GuardianApprovalRequestSource;
|
|
use codex_async_utils::OrCancelExt;
|
|
use codex_core_plugins::PluginCommandAttribution;
|
|
use codex_extension_api::LoadedUserInstructions;
|
|
use codex_plugin::PluginId;
|
|
use codex_protocol::items::is_safe_plugin_relative_path;
|
|
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
|
use codex_protocol::protocol::Event;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
|
use codex_protocol::protocol::McpInvocation;
|
|
use codex_protocol::protocol::Op;
|
|
use codex_protocol::protocol::RequestUserInputEvent;
|
|
use codex_protocol::protocol::ReviewDecision;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use codex_protocol::protocol::Submission;
|
|
use codex_protocol::protocol::ThreadSource;
|
|
use codex_protocol::request_permissions::PermissionGrantScope;
|
|
use codex_protocol::request_permissions::RequestPermissionsArgs;
|
|
use codex_protocol::request_permissions::RequestPermissionsEvent;
|
|
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
|
use codex_protocol::request_user_input::RequestUserInputArgs;
|
|
use codex_protocol::request_user_input::RequestUserInputResponse;
|
|
use codex_protocol::user_input::UserInput;
|
|
use serde_json::Value;
|
|
use std::time::Duration;
|
|
use tokio::sync::Mutex;
|
|
use tokio::sync::oneshot;
|
|
use tokio::time::timeout;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use crate::config::Config;
|
|
use crate::guardian::GuardianApprovalRequest;
|
|
use crate::guardian::GuardianReviewOptions;
|
|
use crate::guardian::new_guardian_review_id;
|
|
use crate::guardian::routes_approval_to_guardian;
|
|
use crate::guardian::routes_approval_to_guardian_with_reviewer;
|
|
use crate::guardian::spawn_approval_request_review;
|
|
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT;
|
|
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION;
|
|
use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC;
|
|
use crate::mcp_tool_call::McpToolApprovalMetadata;
|
|
use crate::mcp_tool_call::build_guardian_mcp_tool_review_request;
|
|
use crate::mcp_tool_call::is_mcp_tool_approval_question_id;
|
|
use crate::mcp_tool_call::mcp_approvals_reviewer;
|
|
use crate::session::ForkPersistence;
|
|
use crate::session::GitEnrichmentPolicy;
|
|
use crate::session::SUBMISSION_CHANNEL_CAPACITY;
|
|
use crate::session::SessionIo;
|
|
use crate::session::SessionSpawnArgs;
|
|
use crate::session::emit_subagent_session_started;
|
|
use crate::session::session::Session;
|
|
use crate::session::turn_context::TurnContext;
|
|
use codex_login::AuthManager;
|
|
use codex_models_manager::manager::SharedModelsManager;
|
|
use codex_protocol::error::CodexErr;
|
|
use codex_protocol::protocol::InitialHistory;
|
|
use codex_protocol::protocol::MultiAgentVersion;
|
|
|
|
#[cfg(test)]
|
|
use crate::session::completed_session_loop_termination;
|
|
|
|
#[derive(Clone)]
|
|
struct PendingMcpInvocation {
|
|
invocation: McpInvocation,
|
|
metadata: Option<McpToolApprovalMetadata>,
|
|
}
|
|
|
|
/// Start an interactive sub-Codex thread and return its runtime and IO channels.
|
|
///
|
|
/// The returned IO yields non-approval events emitted by the sub-agent.
|
|
/// Approval requests are handled via `parent_session` and are not surfaced.
|
|
/// Its submission channel accepts additional `Op`s for the sub-agent.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn run_codex_thread_interactive(
|
|
config: Config,
|
|
auth_manager: Arc<AuthManager>,
|
|
models_manager: SharedModelsManager,
|
|
parent_session: Arc<Session>,
|
|
parent_ctx: Arc<TurnContext>,
|
|
cancel_token: CancellationToken,
|
|
subagent_source: SubAgentSource,
|
|
initial_history: Option<InitialHistory>,
|
|
git_enrichment_policy: GitEnrichmentPolicy,
|
|
windows_sandbox_proxy_settings_mode: codex_sandboxing::WindowsSandboxProxySettingsMode,
|
|
) -> Result<(Arc<Session>, SessionIo), CodexErr> {
|
|
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
|
let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
|
let conversation_history = initial_history.unwrap_or(InitialHistory::New);
|
|
let forked_from_thread_id = conversation_history.forked_from_id();
|
|
let user_instructions = LoadedUserInstructions {
|
|
instructions: parent_session.user_instructions().await,
|
|
warnings: Vec::new(),
|
|
};
|
|
let (session, io) = Box::pin(Session::spawn(SessionSpawnArgs {
|
|
config,
|
|
allow_provider_model_fallback: false,
|
|
user_instructions,
|
|
installation_id: parent_session.installation_id.clone(),
|
|
auth_manager,
|
|
models_manager,
|
|
environment_manager: parent_session
|
|
.services
|
|
.turn_environments
|
|
.environment_manager(),
|
|
skills_service: Arc::clone(&parent_session.services.skills_service),
|
|
plugins_manager: Arc::clone(&parent_session.services.plugins_manager),
|
|
mcp_manager: Arc::clone(&parent_session.services.mcp_manager),
|
|
code_mode_session_provider: parent_session.services.code_mode_service.session_provider(),
|
|
extensions: Arc::clone(&parent_session.services.extensions),
|
|
conversation_history,
|
|
requested_history_mode: None,
|
|
fork_persistence: ForkPersistence::Copied,
|
|
session_source: SessionSource::SubAgent(subagent_source.clone()),
|
|
forked_from_thread_id,
|
|
parent_thread_id: Some(parent_session.thread_id),
|
|
thread_source: Some(ThreadSource::Subagent),
|
|
originator: parent_ctx.originator.clone(),
|
|
agent_control: parent_session.services.agent_control.clone(),
|
|
dynamic_tools: Vec::new(),
|
|
metrics_service_name: None,
|
|
user_shell_override: None,
|
|
inherited_environments: Some(parent_ctx.environments.clone()),
|
|
inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)),
|
|
parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(),
|
|
parent_trace: None,
|
|
environment_selections: parent_ctx.environments.to_selections(),
|
|
thread_extension_init: codex_extension_api::ExtensionDataInit::default(),
|
|
client_mcp_extensions: parent_session.services.client_mcp_extensions.clone(),
|
|
analytics_events_client: Some(parent_session.services.analytics_events_client.clone()),
|
|
thread_store: Arc::clone(&parent_session.services.thread_store),
|
|
attestation_provider: parent_session.services.attestation_provider.clone(),
|
|
external_time_provider: Some(Arc::clone(&parent_session.services.time_provider)),
|
|
inherited_multi_agent_version: Some(MultiAgentVersion::Disabled),
|
|
git_enrichment_policy,
|
|
windows_sandbox_proxy_settings_mode,
|
|
}))
|
|
.or_cancel(&cancel_token)
|
|
.await??;
|
|
let thread_config = session.thread_config_snapshot().await;
|
|
let client_metadata = parent_session.app_server_client_metadata().await;
|
|
emit_subagent_session_started(
|
|
&parent_session.services.analytics_events_client,
|
|
client_metadata,
|
|
session.session_id(),
|
|
session.thread_id(),
|
|
Some(parent_session.thread_id),
|
|
thread_config,
|
|
subagent_source,
|
|
);
|
|
// Use a child token so parent cancel cascades but we can scope it to this task
|
|
let cancel_token_events = cancel_token.child_token();
|
|
let cancel_token_ops = cancel_token.child_token();
|
|
|
|
// Forward events from the sub-agent to the consumer, filtering approvals and
|
|
// routing them to the parent session for decisions.
|
|
let parent_session_clone = Arc::clone(&parent_session);
|
|
let parent_ctx_clone = Arc::clone(&parent_ctx);
|
|
let session_for_events = Arc::clone(&session);
|
|
let io = Arc::new(io);
|
|
// Cache the child call's MCP metadata at begin time. The later legacy
|
|
// RequestUserInput approval event only carries a call_id and question metadata.
|
|
let pending_mcp_invocations =
|
|
Arc::new(Mutex::new(HashMap::<String, PendingMcpInvocation>::new()));
|
|
let caller_io = SessionIo {
|
|
tx_sub: tx_ops,
|
|
rx_event: rx_sub,
|
|
agent_status: io.agent_status.clone(),
|
|
session_loop_termination: io.session_loop_termination.clone(),
|
|
};
|
|
let io_for_events = Arc::clone(&io);
|
|
tokio::spawn(async move {
|
|
forward_events(
|
|
io_for_events,
|
|
session_for_events,
|
|
tx_sub,
|
|
parent_session_clone,
|
|
parent_ctx_clone,
|
|
pending_mcp_invocations,
|
|
cancel_token_events,
|
|
)
|
|
.await;
|
|
});
|
|
|
|
// Forward ops from the caller to the sub-agent.
|
|
tokio::spawn(async move {
|
|
forward_ops(io, rx_ops, cancel_token_ops).await;
|
|
});
|
|
|
|
Ok((session, caller_io))
|
|
}
|
|
|
|
/// Convenience wrapper for one-time use with an initial prompt.
|
|
///
|
|
/// Internally calls the interactive variant, then immediately submits the provided input.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) async fn run_codex_thread_one_shot(
|
|
config: Config,
|
|
auth_manager: Arc<AuthManager>,
|
|
models_manager: SharedModelsManager,
|
|
input: Vec<UserInput>,
|
|
parent_session: Arc<Session>,
|
|
parent_ctx: Arc<TurnContext>,
|
|
cancel_token: CancellationToken,
|
|
subagent_source: SubAgentSource,
|
|
final_output_json_schema: Option<Value>,
|
|
initial_history: Option<InitialHistory>,
|
|
) -> Result<(Arc<Session>, SessionIo), CodexErr> {
|
|
// Use a child token so we can stop the delegate after completion without
|
|
// requiring the caller to cancel the parent token.
|
|
let child_cancel = cancel_token.child_token();
|
|
let parent_turn_id = parent_ctx.sub_id.clone();
|
|
let (session, io) = Box::pin(run_codex_thread_interactive(
|
|
config,
|
|
auth_manager,
|
|
models_manager,
|
|
parent_session,
|
|
parent_ctx,
|
|
child_cancel.clone(),
|
|
subagent_source,
|
|
initial_history,
|
|
GitEnrichmentPolicy::Fresh,
|
|
codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile,
|
|
))
|
|
.await?;
|
|
|
|
// Send the initial input to kick off the one-shot turn.
|
|
io.submit_with_trace(
|
|
Op::UserInput {
|
|
items: input,
|
|
final_output_json_schema,
|
|
responsesapi_client_metadata: None,
|
|
additional_context: Default::default(),
|
|
thread_settings: Default::default(),
|
|
},
|
|
/*trace*/ None,
|
|
Some(parent_turn_id),
|
|
)
|
|
.await?;
|
|
|
|
// Bridge events so we can observe completion and shut down automatically.
|
|
let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
|
let ops_tx = io.tx_sub.clone();
|
|
let agent_status = io.agent_status.clone();
|
|
let session_loop_termination = io.session_loop_termination.clone();
|
|
let io_for_bridge = io;
|
|
tokio::spawn(async move {
|
|
while let Ok(event) = io_for_bridge.next_event().await {
|
|
let should_shutdown = matches!(
|
|
event.msg,
|
|
EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_)
|
|
);
|
|
let _ = tx_bridge.send(event).await;
|
|
if should_shutdown {
|
|
let _ = ops_tx
|
|
.send(Submission {
|
|
id: "shutdown".to_string(),
|
|
op: Op::Shutdown {},
|
|
client_user_message_id: None,
|
|
trace: None,
|
|
parent_turn_id: None,
|
|
})
|
|
.await;
|
|
child_cancel.cancel();
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
// For one-shot usage, return a closed `tx_sub` so callers cannot submit
|
|
// additional ops after the initial request. Create a channel and drop the
|
|
// receiver to close it immediately.
|
|
let (tx_closed, rx_closed) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
|
drop(rx_closed);
|
|
|
|
Ok((
|
|
session,
|
|
SessionIo {
|
|
rx_event: rx_bridge,
|
|
tx_sub: tx_closed,
|
|
agent_status,
|
|
session_loop_termination,
|
|
},
|
|
))
|
|
}
|
|
|
|
async fn forward_events(
|
|
io: Arc<SessionIo>,
|
|
session: Arc<Session>,
|
|
tx_sub: Sender<Event>,
|
|
parent_session: Arc<Session>,
|
|
parent_ctx: Arc<TurnContext>,
|
|
pending_mcp_invocations: Arc<Mutex<HashMap<String, PendingMcpInvocation>>>,
|
|
cancel_token: CancellationToken,
|
|
) {
|
|
let cancelled = cancel_token.cancelled();
|
|
tokio::pin!(cancelled);
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = &mut cancelled => {
|
|
shutdown_delegate(&io).await;
|
|
break;
|
|
}
|
|
event = io.next_event() => {
|
|
let event = match event {
|
|
Ok(event) => event,
|
|
Err(_) => break,
|
|
};
|
|
match event {
|
|
Event {
|
|
id: _,
|
|
msg:
|
|
EventMsg::TokenCount(_)
|
|
| EventMsg::SessionConfigured(_)
|
|
| EventMsg::McpStartupUpdate(_)
|
|
| EventMsg::McpStartupComplete(_),
|
|
} => {}
|
|
Event {
|
|
id,
|
|
msg: EventMsg::ExecApprovalRequest(event),
|
|
} => {
|
|
// Initiate approval via parent session; do not surface to consumer.
|
|
handle_exec_approval(
|
|
&io,
|
|
id,
|
|
&parent_session,
|
|
&parent_ctx,
|
|
event,
|
|
&cancel_token,
|
|
)
|
|
.await;
|
|
}
|
|
Event {
|
|
id,
|
|
msg: EventMsg::ApplyPatchApprovalRequest(event),
|
|
} => {
|
|
handle_patch_approval(
|
|
&io,
|
|
id,
|
|
&parent_session,
|
|
&parent_ctx,
|
|
event,
|
|
&cancel_token,
|
|
)
|
|
.await;
|
|
}
|
|
Event {
|
|
msg: EventMsg::RequestPermissions(event),
|
|
..
|
|
} => {
|
|
handle_request_permissions(
|
|
&io,
|
|
&parent_session,
|
|
&parent_ctx,
|
|
event,
|
|
&cancel_token,
|
|
)
|
|
.await;
|
|
}
|
|
Event {
|
|
id,
|
|
msg: EventMsg::RequestUserInput(event),
|
|
} => {
|
|
handle_request_user_input(
|
|
&io,
|
|
id,
|
|
&parent_session,
|
|
&parent_ctx,
|
|
&pending_mcp_invocations,
|
|
event,
|
|
&cancel_token,
|
|
)
|
|
.await;
|
|
}
|
|
Event {
|
|
id,
|
|
msg: EventMsg::McpToolCallBegin(event),
|
|
} => {
|
|
// The later approval event has only a call ID. Retain the exact facts
|
|
// captured before this begin event instead of consulting the latest
|
|
// runtime after a refresh.
|
|
let metadata = session
|
|
.mcp_tool_approval_metadata(&id, &event.call_id)
|
|
.await
|
|
.map(|(_, metadata)| metadata);
|
|
pending_mcp_invocations
|
|
.lock()
|
|
.await
|
|
.insert(
|
|
event.call_id.clone(),
|
|
PendingMcpInvocation {
|
|
invocation: event.invocation.clone(),
|
|
metadata,
|
|
},
|
|
);
|
|
if !forward_event_or_shutdown(
|
|
&io,
|
|
&tx_sub,
|
|
&cancel_token,
|
|
Event {
|
|
id,
|
|
msg: EventMsg::McpToolCallBegin(event),
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
Event {
|
|
id,
|
|
msg: EventMsg::McpToolCallEnd(event),
|
|
} => {
|
|
pending_mcp_invocations.lock().await.remove(&event.call_id);
|
|
if !forward_event_or_shutdown(
|
|
&io,
|
|
&tx_sub,
|
|
&cancel_token,
|
|
Event {
|
|
id,
|
|
msg: EventMsg::McpToolCallEnd(event),
|
|
},
|
|
)
|
|
.await
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
other => {
|
|
if !forward_event_or_shutdown(&io, &tx_sub, &cancel_token, other).await
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ask the delegate to stop and drain its events so background sends do not hit a closed channel.
|
|
async fn shutdown_delegate(io: &SessionIo) {
|
|
let _ = io.submit(Op::Interrupt).await;
|
|
let _ = io.submit(Op::Shutdown {}).await;
|
|
|
|
let _ = timeout(Duration::from_millis(500), async {
|
|
while let Ok(event) = io.next_event().await {
|
|
if matches!(
|
|
event.msg,
|
|
EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)
|
|
) {
|
|
break;
|
|
}
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
async fn forward_event_or_shutdown(
|
|
io: &SessionIo,
|
|
tx_sub: &Sender<Event>,
|
|
cancel_token: &CancellationToken,
|
|
event: Event,
|
|
) -> bool {
|
|
match tx_sub.send(event).or_cancel(cancel_token).await {
|
|
Ok(Ok(())) => true,
|
|
_ => {
|
|
shutdown_delegate(io).await;
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Forward ops from a caller to a sub-agent, respecting cancellation.
|
|
async fn forward_ops(
|
|
io: Arc<SessionIo>,
|
|
rx_ops: Receiver<Submission>,
|
|
cancel_token_ops: CancellationToken,
|
|
) {
|
|
loop {
|
|
let submission = match rx_ops.recv().or_cancel(&cancel_token_ops).await {
|
|
Ok(Ok(submission)) => submission,
|
|
Ok(Err(_)) | Err(_) => break,
|
|
};
|
|
let _ = io.submit_with_id(submission).await;
|
|
}
|
|
}
|
|
|
|
/// Handle an ExecApprovalRequest by consulting the parent session and replying.
|
|
async fn handle_exec_approval(
|
|
io: &SessionIo,
|
|
turn_id: String,
|
|
parent_session: &Arc<Session>,
|
|
parent_ctx: &Arc<TurnContext>,
|
|
event: ExecApprovalRequestEvent,
|
|
cancel_token: &CancellationToken,
|
|
) {
|
|
let approval_id_for_op = event.effective_approval_id();
|
|
let ExecApprovalRequestEvent {
|
|
call_id,
|
|
plugin_id,
|
|
script_path,
|
|
approval_id,
|
|
environment_id,
|
|
command,
|
|
cwd,
|
|
reason,
|
|
network_approval_context,
|
|
proposed_execpolicy_amendment,
|
|
additional_permissions,
|
|
available_decisions,
|
|
..
|
|
} = event;
|
|
let plugin_attribution = plugin_id
|
|
.zip(script_path)
|
|
.and_then(|(plugin_id, script_path)| {
|
|
let plugin_id = PluginId::parse(&plugin_id).ok()?;
|
|
is_safe_plugin_relative_path(&script_path).then_some(PluginCommandAttribution {
|
|
plugin_id,
|
|
normalized_relative_path: script_path,
|
|
})
|
|
});
|
|
let decision = if routes_approval_to_guardian(parent_ctx) {
|
|
let review_cancel = cancel_token.child_token();
|
|
let review_rx = spawn_approval_request_review(
|
|
Arc::clone(parent_session),
|
|
Arc::clone(parent_ctx),
|
|
new_guardian_review_id(),
|
|
GuardianApprovalRequest::Shell {
|
|
id: call_id.clone(),
|
|
command,
|
|
cwd,
|
|
sandbox_permissions: if additional_permissions.is_some() {
|
|
crate::sandboxing::SandboxPermissions::WithAdditionalPermissions
|
|
} else {
|
|
crate::sandboxing::SandboxPermissions::UseDefault
|
|
},
|
|
additional_permissions,
|
|
justification: None,
|
|
},
|
|
reason,
|
|
GuardianReviewOptions {
|
|
plugin_attribution_override: plugin_attribution.clone(),
|
|
approval_request_source: GuardianApprovalRequestSource::DelegatedSubagent,
|
|
external_cancel: Some(review_cancel.clone()),
|
|
},
|
|
);
|
|
await_approval_with_cancel(
|
|
receive_approval_review(review_rx),
|
|
parent_session,
|
|
&approval_id_for_op,
|
|
cancel_token,
|
|
Some(&review_cancel),
|
|
)
|
|
.await
|
|
} else {
|
|
await_approval_with_cancel(
|
|
parent_session.request_command_approval(
|
|
parent_ctx,
|
|
call_id,
|
|
approval_id,
|
|
environment_id,
|
|
command,
|
|
cwd,
|
|
reason,
|
|
network_approval_context,
|
|
proposed_execpolicy_amendment,
|
|
additional_permissions,
|
|
available_decisions,
|
|
plugin_attribution,
|
|
),
|
|
parent_session,
|
|
&approval_id_for_op,
|
|
cancel_token,
|
|
/*review_cancel_token*/ None,
|
|
)
|
|
.await
|
|
};
|
|
|
|
let _ = io
|
|
.submit(Op::ExecApproval {
|
|
id: approval_id_for_op,
|
|
turn_id: Some(turn_id),
|
|
decision,
|
|
})
|
|
.await;
|
|
}
|
|
|
|
/// Handle an ApplyPatchApprovalRequest by consulting the parent session and replying.
|
|
async fn handle_patch_approval(
|
|
io: &SessionIo,
|
|
_id: String,
|
|
parent_session: &Arc<Session>,
|
|
parent_ctx: &Arc<TurnContext>,
|
|
event: ApplyPatchApprovalRequestEvent,
|
|
cancel_token: &CancellationToken,
|
|
) {
|
|
let ApplyPatchApprovalRequestEvent {
|
|
call_id,
|
|
changes,
|
|
reason,
|
|
grant_root,
|
|
..
|
|
} = event;
|
|
let approval_id = call_id.clone();
|
|
let guardian_decision = if routes_approval_to_guardian(parent_ctx) {
|
|
let files = changes
|
|
.keys()
|
|
.map(|path| {
|
|
#[allow(deprecated)]
|
|
parent_ctx.cwd.join(path)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let review_cancel = cancel_token.child_token();
|
|
let patch = changes
|
|
.iter()
|
|
.map(|(path, change)| match change {
|
|
codex_protocol::protocol::FileChange::Add { content } => {
|
|
format!("*** Add File: {}\n{}", path.display(), content)
|
|
}
|
|
codex_protocol::protocol::FileChange::Delete { content } => {
|
|
format!("*** Delete File: {}\n{}", path.display(), content)
|
|
}
|
|
codex_protocol::protocol::FileChange::Update {
|
|
unified_diff,
|
|
move_path,
|
|
} => {
|
|
if let Some(move_path) = move_path {
|
|
format!(
|
|
"*** Update File: {}\n*** Move to: {}\n{}",
|
|
path.display(),
|
|
move_path.display(),
|
|
unified_diff
|
|
)
|
|
} else {
|
|
format!("*** Update File: {}\n{}", path.display(), unified_diff)
|
|
}
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
let review_rx = spawn_approval_request_review(
|
|
Arc::clone(parent_session),
|
|
Arc::clone(parent_ctx),
|
|
new_guardian_review_id(),
|
|
GuardianApprovalRequest::ApplyPatch {
|
|
id: approval_id.clone(),
|
|
#[allow(deprecated)]
|
|
cwd: parent_ctx.cwd.clone(),
|
|
files,
|
|
patch,
|
|
},
|
|
reason.clone(),
|
|
GuardianReviewOptions {
|
|
plugin_attribution_override: None,
|
|
approval_request_source: GuardianApprovalRequestSource::DelegatedSubagent,
|
|
external_cancel: Some(review_cancel.clone()),
|
|
},
|
|
);
|
|
Some(
|
|
await_approval_with_cancel(
|
|
receive_approval_review(review_rx),
|
|
parent_session,
|
|
&approval_id,
|
|
cancel_token,
|
|
Some(&review_cancel),
|
|
)
|
|
.await,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
let decision = if let Some(decision) = guardian_decision {
|
|
decision
|
|
} else {
|
|
let decision =
|
|
parent_session.request_patch_approval(parent_ctx, call_id, changes, reason, grant_root);
|
|
await_approval_with_cancel(
|
|
decision,
|
|
parent_session,
|
|
&approval_id,
|
|
cancel_token,
|
|
/*review_cancel_token*/ None,
|
|
)
|
|
.await
|
|
};
|
|
let _ = io
|
|
.submit(Op::PatchApproval {
|
|
id: approval_id,
|
|
decision,
|
|
})
|
|
.await;
|
|
}
|
|
|
|
async fn handle_request_user_input(
|
|
io: &SessionIo,
|
|
id: String,
|
|
parent_session: &Arc<Session>,
|
|
parent_ctx: &Arc<TurnContext>,
|
|
pending_mcp_invocations: &Arc<Mutex<HashMap<String, PendingMcpInvocation>>>,
|
|
event: RequestUserInputEvent,
|
|
cancel_token: &CancellationToken,
|
|
) {
|
|
if let Some(response) = maybe_auto_review_mcp_request_user_input(
|
|
parent_session,
|
|
parent_ctx,
|
|
pending_mcp_invocations,
|
|
&event,
|
|
cancel_token,
|
|
)
|
|
.await
|
|
{
|
|
let _ = io.submit(Op::UserInputAnswer { id, response }).await;
|
|
return;
|
|
}
|
|
|
|
let args = RequestUserInputArgs {
|
|
questions: event.questions,
|
|
is_blocking: event.is_blocking,
|
|
auto_resolution_ms: event.auto_resolution_ms,
|
|
};
|
|
let response_fut =
|
|
parent_session.request_user_input(parent_ctx, parent_ctx.sub_id.clone(), args);
|
|
let response = await_user_input_with_cancel(
|
|
response_fut,
|
|
parent_session,
|
|
&parent_ctx.sub_id,
|
|
cancel_token,
|
|
)
|
|
.await;
|
|
let _ = io.submit(Op::UserInputAnswer { id, response }).await;
|
|
}
|
|
|
|
/// Intercepts delegated legacy MCP approval prompts on the RequestUserInput
|
|
/// compatibility path and, when guardian is active, answers them
|
|
/// programmatically after running the guardian review.
|
|
///
|
|
/// The RequestUserInput event only carries `call_id` plus approval question
|
|
/// metadata, so this helper joins it back to the child runtime metadata cached at
|
|
/// `McpToolCallBegin` in order to rebuild the full guardian review request.
|
|
async fn maybe_auto_review_mcp_request_user_input(
|
|
parent_session: &Arc<Session>,
|
|
parent_ctx: &Arc<TurnContext>,
|
|
pending_mcp_invocations: &Arc<Mutex<HashMap<String, PendingMcpInvocation>>>,
|
|
event: &RequestUserInputEvent,
|
|
cancel_token: &CancellationToken,
|
|
) -> Option<RequestUserInputResponse> {
|
|
// TODO(ccunningham): Support delegated MCP approval elicitations here too after
|
|
// coordinating with @fouad. Today guardian only auto-reviews the RequestUserInput
|
|
// compatibility path for delegated MCP approvals.
|
|
let question = event
|
|
.questions
|
|
.iter()
|
|
.find(|question| is_mcp_tool_approval_question_id(&question.id))?;
|
|
let pending = pending_mcp_invocations
|
|
.lock()
|
|
.await
|
|
.get(&event.call_id)
|
|
.cloned()?;
|
|
let invocation = pending.invocation;
|
|
let metadata = pending.metadata;
|
|
let approvals_reviewer =
|
|
mcp_approvals_reviewer(parent_ctx, &invocation.server, metadata.as_ref());
|
|
if !routes_approval_to_guardian_with_reviewer(parent_ctx, approvals_reviewer) {
|
|
return None;
|
|
}
|
|
let review_cancel = cancel_token.child_token();
|
|
let review_rx = spawn_approval_request_review(
|
|
Arc::clone(parent_session),
|
|
Arc::clone(parent_ctx),
|
|
new_guardian_review_id(),
|
|
build_guardian_mcp_tool_review_request(&event.call_id, &invocation, metadata.as_ref()),
|
|
/*retry_reason*/ None,
|
|
GuardianReviewOptions {
|
|
plugin_attribution_override: None,
|
|
approval_request_source: GuardianApprovalRequestSource::DelegatedSubagent,
|
|
external_cancel: Some(review_cancel.clone()),
|
|
},
|
|
);
|
|
let decision = await_approval_with_cancel(
|
|
receive_approval_review(review_rx),
|
|
parent_session,
|
|
&event.call_id,
|
|
cancel_token,
|
|
Some(&review_cancel),
|
|
)
|
|
.await;
|
|
let selected_label = match decision {
|
|
ReviewDecision::ApprovedForSession => question
|
|
.options
|
|
.as_ref()
|
|
.and_then(|options| {
|
|
options
|
|
.iter()
|
|
.find(|option| option.label == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION)
|
|
})
|
|
.map(|option| option.label.clone())
|
|
.unwrap_or_else(|| MCP_TOOL_APPROVAL_ACCEPT.to_string()),
|
|
ReviewDecision::Approved
|
|
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
|
| ReviewDecision::NetworkPolicyAmendment { .. } => MCP_TOOL_APPROVAL_ACCEPT.to_string(),
|
|
ReviewDecision::Denied { .. } | ReviewDecision::TimedOut | ReviewDecision::Abort => {
|
|
MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC.to_string()
|
|
}
|
|
};
|
|
Some(RequestUserInputResponse {
|
|
answers: HashMap::from([(
|
|
question.id.clone(),
|
|
codex_protocol::request_user_input::RequestUserInputAnswer {
|
|
answers: vec![selected_label],
|
|
},
|
|
)]),
|
|
})
|
|
}
|
|
|
|
async fn handle_request_permissions(
|
|
io: &SessionIo,
|
|
parent_session: &Arc<Session>,
|
|
parent_ctx: &Arc<TurnContext>,
|
|
event: RequestPermissionsEvent,
|
|
cancel_token: &CancellationToken,
|
|
) {
|
|
let call_id = event.call_id;
|
|
let args = RequestPermissionsArgs {
|
|
environment_id: event.environment_id,
|
|
reason: event.reason,
|
|
permissions: event.permissions,
|
|
};
|
|
let cwd = event.cwd.unwrap_or_else(|| {
|
|
#[allow(deprecated)]
|
|
parent_ctx.cwd.clone()
|
|
});
|
|
let response_fut = parent_session.request_permissions_for_cwd(
|
|
parent_ctx,
|
|
call_id.clone(),
|
|
args,
|
|
cwd,
|
|
cancel_token.clone(),
|
|
);
|
|
let response =
|
|
await_request_permissions_with_cancel(response_fut, parent_session, &call_id, cancel_token)
|
|
.await;
|
|
let _ = io
|
|
.submit(Op::RequestPermissionsResponse {
|
|
id: call_id,
|
|
response,
|
|
})
|
|
.await;
|
|
}
|
|
|
|
async fn await_user_input_with_cancel<F>(
|
|
fut: F,
|
|
parent_session: &Session,
|
|
sub_id: &str,
|
|
cancel_token: &CancellationToken,
|
|
) -> RequestUserInputResponse
|
|
where
|
|
F: core::future::Future<Output = Option<RequestUserInputResponse>>,
|
|
{
|
|
tokio::select! {
|
|
biased;
|
|
_ = cancel_token.cancelled() => {
|
|
let empty = RequestUserInputResponse {
|
|
answers: HashMap::new(),
|
|
};
|
|
parent_session
|
|
.notify_user_input_response(sub_id, empty.clone())
|
|
.await;
|
|
empty
|
|
}
|
|
response = fut => response.unwrap_or_else(|| RequestUserInputResponse {
|
|
answers: HashMap::new(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
async fn await_request_permissions_with_cancel<F>(
|
|
fut: F,
|
|
parent_session: &Session,
|
|
call_id: &str,
|
|
cancel_token: &CancellationToken,
|
|
) -> RequestPermissionsResponse
|
|
where
|
|
F: core::future::Future<Output = Option<RequestPermissionsResponse>>,
|
|
{
|
|
tokio::select! {
|
|
biased;
|
|
_ = cancel_token.cancelled() => {
|
|
let empty = RequestPermissionsResponse {
|
|
permissions: Default::default(),
|
|
scope: PermissionGrantScope::Turn,
|
|
strict_auto_review: false,
|
|
};
|
|
parent_session
|
|
.notify_request_permissions_response(call_id, empty.clone())
|
|
.await;
|
|
empty
|
|
}
|
|
response = fut => response.unwrap_or_else(|| RequestPermissionsResponse {
|
|
permissions: Default::default(),
|
|
scope: PermissionGrantScope::Turn,
|
|
strict_auto_review: false,
|
|
}),
|
|
}
|
|
}
|
|
|
|
async fn receive_approval_review(review_rx: oneshot::Receiver<ReviewDecision>) -> ReviewDecision {
|
|
review_rx
|
|
.await
|
|
.unwrap_or_else(|_| ReviewDecision::denied("automatic approval review could not complete"))
|
|
}
|
|
|
|
/// Await an approval decision, aborting on cancellation.
|
|
async fn await_approval_with_cancel<F>(
|
|
fut: F,
|
|
parent_session: &Session,
|
|
approval_id: &str,
|
|
cancel_token: &CancellationToken,
|
|
review_cancel_token: Option<&CancellationToken>,
|
|
) -> codex_protocol::protocol::ReviewDecision
|
|
where
|
|
F: core::future::Future<Output = codex_protocol::protocol::ReviewDecision>,
|
|
{
|
|
tokio::select! {
|
|
biased;
|
|
_ = cancel_token.cancelled() => {
|
|
if let Some(review_cancel_token) = review_cancel_token {
|
|
review_cancel_token.cancel();
|
|
}
|
|
parent_session
|
|
.notify_approval(
|
|
approval_id,
|
|
codex_protocol::protocol::ReviewDecision::Abort,
|
|
)
|
|
.await;
|
|
codex_protocol::protocol::ReviewDecision::Abort
|
|
}
|
|
decision = fut => {
|
|
decision
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "codex_delegate_tests.rs"]
|
|
mod tests;
|