Files
codex/codex-rs/tui/src/app/user_verification.rs
T
Benjamin Carlsson 6f47fcb0be Route TUI request replies to their originating connection and thread (#53436)
## Why

Pending requests can outlive a session switch, and different threads or connections can reuse request IDs. Replies and resolution notifications must stay associated with the request that prompted them.

## What changed

- Retain the original app-server reply handle for pending and buffered requests, including requests replayed after thread attachment or session reset.
- Scope user input, MCP elicitation, and user verification bookkeeping by thread, and send user input answers to the requesting thread.
- Keep background resolution notifications from dismissing foreground prompts.
- Consume failed replies to closed connections and clear pending replay state without falling through to another request route.

## Testing

Add WebSocket regression tests for direct and buffered approvals with colliding request IDs and replies to closed connections. Extend coverage for foreground prompt preservation and thread-scoped user verification cancellation.

GitOrigin-RevId: 541acb9910e92f6f2e83c27d2a2384185d4f8fec
2026-10-12 17:39:17 +00:00

154 lines
5.6 KiB
Rust

//! Coordinates approval through the local app-server, keeping proofs out of views.
//!
//! Attempt IDs are UI-local generations: cancellation, resolution, or reconnect removes the
//! pending generation, so a late RPC response cannot approve a newly surfaced request.
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::UserVerificationProof;
use codex_app_server_protocol::UserVerificationVerifyResponse;
use codex_app_server_protocol::WarningNotification;
use codex_protocol::ThreadId;
use uuid::Uuid;
use super::App;
use super::app_server_requests::ResolvedAppServerRequest;
use super::user_verification_errors::verification_failure;
use crate::app_command::AppCommand;
use crate::app_command::UserVerificationFailure;
use crate::app_command::UserVerificationResponse;
use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use codex_app_server_protocol::UserVerificationErrorDetails;
use codex_app_server_protocol::UserVerificationUnavailableReason;
impl App {
pub(super) async fn start_user_verification(
&mut self,
app_server: &AppServerSession,
thread_id: ThreadId,
server_name: String,
request_id: RequestId,
) -> color_eyre::Result<()> {
if app_server.uses_remote_workspace() {
self.chat_widget.dismiss_app_server_request(
&ResolvedAppServerRequest::McpElicitation {
server_name: server_name.clone(),
request_id: request_id.clone(),
},
);
self.enqueue_thread_notification(
thread_id,
ServerNotification::Warning(WarningNotification {
thread_id: Some(thread_id.to_string()),
message: "User verification is unavailable for remote workspaces.".to_string(),
}),
)
.await?;
self.app_event_tx.resolve_user_verification(
thread_id,
server_name,
request_id,
UserVerificationResponse::Failed {
error: UserVerificationErrorDetails::Unavailable {
reason: UserVerificationUnavailableReason::ProviderUnavailable,
},
},
);
return Ok(());
}
let Some(attempt) = self.pending_app_server_requests.user_verification.begin(
&thread_id.to_string(),
&server_name,
&request_id,
) else {
return Ok(());
};
let handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let attempt_id = attempt.id;
let verification = handle.request_typed::<UserVerificationVerifyResponse>(
ClientRequest::UserVerificationVerify {
request_id: RequestId::String(format!("user-verification-{attempt_id}")),
params: attempt.params,
},
);
let result = tokio::select! {
biased;
_ = attempt.cancelled.cancelled() => return,
result = verification => result,
}
.map(|response| response.proof)
.map_err(|error| verification_failure(&error));
app_event_tx.send(AppEvent::UserVerificationFinished {
thread_id,
server_name,
request_id,
attempt_id,
result,
});
});
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn finish_user_verification(
&mut self,
app_server: &mut AppServerSession,
thread_id: ThreadId,
server_name: String,
request_id: RequestId,
attempt_id: Uuid,
result: Result<UserVerificationProof, UserVerificationFailure>,
) -> color_eyre::Result<()> {
if self.abandoned_side_threads.contains(&thread_id)
|| !self
.pending_app_server_requests
.user_verification
.is_pending(
&thread_id.to_string(),
&server_name,
&request_id,
attempt_id,
)
{
return Ok(());
}
let response = match result {
Ok(proof) => UserVerificationResponse::Accept { proof },
Err(failure) => {
self.enqueue_thread_notification(
thread_id,
ServerNotification::Warning(WarningNotification {
thread_id: Some(thread_id.to_string()),
message: failure.message.to_string(),
}),
)
.await?;
// Another trusted device may be supported later. For now every incomplete
// verification cancels the original elicitation without a fallback provider.
UserVerificationResponse::Failed {
error: failure.details,
}
}
};
self.chat_widget
.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation {
server_name: server_name.clone(),
request_id: request_id.clone(),
});
self.submit_thread_op(
app_server,
thread_id,
AppCommand::resolve_user_verification(server_name, request_id, response),
)
.await
}
}
#[cfg(test)]
#[path = "user_verification_tests.rs"]
mod tests;