diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 141761e88a..b805f75ba7 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -65,6 +65,9 @@ const TEST_TOOL_NAME: &str = "echo_tool"; const LARGE_RESPONSE_MESSAGE: &str = "large"; const ELICITATION_TRIGGER_MESSAGE: &str = "confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; +const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth"; +const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue."; +const URL_ELICITATION_URL: &str = "https://github.example/login/device"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_server_tool_call_returns_tool_result() -> Result<()> { @@ -294,6 +297,109 @@ url = "{mcp_server_url}/mcp" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_tool_call_forwards_url_elicitation() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &responses_server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.{TEST_SERVER_NAME}] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let tool_call_request_id = mcp + .send_mcp_server_tool_call_request(McpServerToolCallParams { + thread_id: thread.id.clone(), + server: TEST_SERVER_NAME.to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({ + "message": URL_ELICITATION_TRIGGER_MESSAGE, + })), + meta: None, + }) + .await?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { + panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); + }; + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: thread.id, + turn_id: None, + server_name: TEST_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: URL_ELICITATION_MESSAGE.to_string(), + url: URL_ELICITATION_URL.to_string(), + elicitation_id: "github-auth-123".to_string(), + }, + } + ); + + mcp.send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: None, + meta: None, + })?, + ) + .await?; + + let tool_call_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(tool_call_request_id)), + ) + .await??; + let response: McpServerToolCallResponse = to_response(tool_call_response)?; + assert_eq!(response.content.len(), 1); + assert_eq!(response.content[0].get("type"), Some(&json!("text"))); + assert_eq!(response.content[0].get("text"), Some(&json!("accepted"))); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_tool_call_completion_notification_contains_truncated_large_result() -> Result<()> { let call_id = "call-large-mcp"; @@ -528,6 +634,28 @@ impl ServerHandler for ToolAppsMcpServer { return Ok(CallToolResult::success(vec![Content::text(output)])); } + if message == URL_ELICITATION_TRIGGER_MESSAGE { + let result = context + .peer + .create_elicitation(CreateElicitationRequestParams::UrlElicitationParams { + meta: None, + message: URL_ELICITATION_MESSAGE.to_string(), + url: URL_ELICITATION_URL.to_string(), + elicitation_id: "github-auth-123".to_string(), + }) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let output = match result.action { + ElicitationAction::Accept => { + assert_eq!(result.content, Some(json!({}))); + "accepted" + } + ElicitationAction::Decline => "declined", + ElicitationAction::Cancel => "cancelled", + }; + return Ok(CallToolResult::success(vec![Content::text(output)])); + } + let mut result = CallToolResult::structured(json!({ "echoed": message, "threadId": thread_id, diff --git a/codex-rs/codex-mcp/src/auth_elicitation.rs b/codex-rs/codex-mcp/src/auth_elicitation.rs new file mode 100644 index 0000000000..77c7b78c55 --- /dev/null +++ b/codex-rs/codex-mcp/src/auth_elicitation.rs @@ -0,0 +1,347 @@ +//! Auth elicitation helpers. +//! +//! This module owns protocol-neutral auth elicitation parsing and payload shaping. +//! Session orchestration stays in `codex-core`. + +use codex_protocol::mcp::CallToolResult; +use serde::Serialize; + +pub const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +pub const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure"; +pub const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure"; +pub const CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: &str = "auth_reason"; +pub const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id"; +pub const CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: &str = "link_id"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: &str = "error_code"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: &str = "error_http_status_code"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: &str = "error_action"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexAppsConnectorAuthFailure { + pub connector_id: String, + pub connector_name: String, + pub install_url: String, + pub auth_reason: Option, + pub link_id: Option, + pub error_code: Option, + pub error_http_status_code: Option, + pub error_action: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CodexAppsAuthElicitation { + pub meta: serde_json::Value, + pub message: String, + pub url: String, + pub elicitation_id: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CodexAppsAuthElicitationPlan { + pub auth_failure: CodexAppsConnectorAuthFailure, + pub elicitation: CodexAppsAuthElicitation, +} + +#[derive(Serialize)] +struct CodexAppsConnectorAuthFailureMeta<'a> { + is_auth_failure: bool, + connector_id: &'a str, + connector_name: &'a str, + install_url: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + auth_reason: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + link_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error_http_status_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error_action: Option<&'a str>, +} + +pub fn connector_auth_failure_from_tool_result( + result: &CallToolResult, + connector_id: Option<&str>, + connector_name: Option<&str>, + install_url: Option, +) -> Option { + if result.is_error != Some(true) { + return None; + } + + let auth_failure = result + .meta + .as_ref()? + .as_object()? + .get(MCP_TOOL_CODEX_APPS_META_KEY)? + .as_object()? + .get(CONNECTOR_AUTH_FAILURE_META_KEY)? + .as_object()?; + if auth_failure + .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY) + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } + + let connector_id = connector_id + .map(str::trim) + .filter(|connector_id| !connector_id.is_empty())?; + if let Some(auth_failure_connector_id) = + string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY) + && auth_failure_connector_id != connector_id + { + return None; + } + let connector_name = connector_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(connector_id) + .to_string(); + + Some(CodexAppsConnectorAuthFailure { + connector_id: connector_id.to_string(), + connector_name, + install_url: install_url?, + auth_reason: string_auth_failure_field( + auth_failure, + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY, + ), + link_id: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_LINK_ID_KEY), + error_code: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY), + error_http_status_code: auth_failure + .get(CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY) + .and_then(serde_json::Value::as_i64), + error_action: string_auth_failure_field( + auth_failure, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY, + ), + }) +} + +pub fn build_auth_elicitation_plan( + call_id: &str, + result: &CallToolResult, + connector_id: Option<&str>, + connector_name: Option<&str>, + install_url: Option, +) -> Option { + let auth_failure = + connector_auth_failure_from_tool_result(result, connector_id, connector_name, install_url)?; + let elicitation = build_auth_elicitation(call_id, &auth_failure); + Some(CodexAppsAuthElicitationPlan { + auth_failure, + elicitation, + }) +} + +pub fn build_auth_elicitation( + call_id: &str, + auth_failure: &CodexAppsConnectorAuthFailure, +) -> CodexAppsAuthElicitation { + CodexAppsAuthElicitation { + meta: serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: CodexAppsConnectorAuthFailureMeta { + is_auth_failure: true, + connector_id: &auth_failure.connector_id, + connector_name: &auth_failure.connector_name, + install_url: &auth_failure.install_url, + auth_reason: auth_failure.auth_reason.as_deref(), + link_id: auth_failure.link_id.as_deref(), + error_code: auth_failure.error_code.as_deref(), + error_http_status_code: auth_failure.error_http_status_code, + error_action: auth_failure.error_action.as_deref(), + }, + }, + }), + message: auth_elicitation_message(auth_failure), + url: auth_failure.install_url.clone(), + elicitation_id: auth_elicitation_id(call_id), + } +} + +pub fn auth_elicitation_completed_result( + auth_failure: &CodexAppsConnectorAuthFailure, + meta: Option, +) -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": format!( + "Authentication for {} was requested and accepted. Retry this tool call now.", + auth_failure.connector_name + ), + })], + structured_content: None, + is_error: Some(true), + meta, + } +} + +pub fn auth_elicitation_id(call_id: &str) -> String { + format!("codex_apps_auth_{call_id}") +} + +fn string_auth_failure_field( + auth_failure: &serde_json::Map, + key: &str, +) -> Option { + auth_failure + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn auth_elicitation_message(auth_failure: &CodexAppsConnectorAuthFailure) -> String { + match auth_failure.auth_reason.as_deref() { + Some("oauth_upgrade_required") => format!( + "Reconnect {} on ChatGPT to grant the permissions needed for this request.", + auth_failure.connector_name + ), + Some("reauthentication_required") => format!( + "Reconnect {} on ChatGPT to restore access for this request.", + auth_failure.connector_name + ), + Some("missing_link") => format!( + "Sign in to {} on ChatGPT to use it in Codex.", + auth_failure.connector_name + ), + _ => format!( + "Sign in to {} on ChatGPT to continue.", + auth_failure.connector_name + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn 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_META_KEY: { + CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true, + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required", + CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar", + "connector_name": "Untrusted Calendar", + CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123", + CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED", + CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION", + }, + }, + })), + } + } + + #[test] + fn parses_auth_failure_from_trusted_connector_metadata() { + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ), + Some(CodexAppsConnectorAuthFailure { + connector_id: "connector_calendar".to_string(), + connector_name: "Google Calendar".to_string(), + install_url: "https://chatgpt.com/apps/google-calendar/connector_calendar" + .to_string(), + auth_reason: Some("reauthentication_required".to_string()), + link_id: Some("link_123".to_string()), + error_code: Some("UNAUTHORIZED".to_string()), + error_http_status_code: Some(401), + error_action: Some("TRIGGER_REAUTHENTICATION".to_string()), + }) + ); + } + + #[test] + fn rejects_missing_or_mismatched_connector_ids() { + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + /*connector_id*/ None, + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ), + None + ); + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_drive"), + Some("Google Drive"), + Some("https://chatgpt.com/apps/google-drive/connector_drive".to_string()), + ), + None + ); + } + + #[test] + fn builds_url_elicitation_payload() { + let auth_failure = connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ) + .expect("auth failure"); + + assert_eq!( + build_auth_elicitation("call_123", &auth_failure), + CodexAppsAuthElicitation { + meta: serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: { + CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true, + CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar", + "connector_name": "Google Calendar", + "install_url": + "https://chatgpt.com/apps/google-calendar/connector_calendar", + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required", + CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123", + CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED", + CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION", + }, + }, + }), + message: "Reconnect Google Calendar on ChatGPT to restore access for this request." + .to_string(), + url: "https://chatgpt.com/apps/google-calendar/connector_calendar".to_string(), + elicitation_id: "codex_apps_auth_call_123".to_string(), + } + ); + } + + #[test] + fn builds_auth_elicitation_plan() { + let plan = build_auth_elicitation_plan( + "call_123", + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ) + .expect("auth elicitation plan"); + + assert_eq!(plan.auth_failure.connector_name, "Google Calendar"); + assert_eq!(plan.elicitation.elicitation_id, "codex_apps_auth_call_123"); + } +} diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 53992b927b..58b5566e23 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -70,6 +70,7 @@ use url::Url; pub struct McpConnectionManager { clients: HashMap, server_origins: HashMap, + host_owned_codex_apps_enabled: bool, elicitation_requests: ElicitationRequestManager, startup_cancellation_token: CancellationToken, } @@ -82,6 +83,7 @@ impl McpConnectionManager { Self { clients: HashMap::new(), server_origins: HashMap::new(), + host_owned_codex_apps_enabled: false, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), permission_profile.get().clone(), @@ -116,6 +118,10 @@ impl McpConnectionManager { self.server_origins.get(server_name).map(String::as_str) } + pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { + self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME + } + pub fn set_approval_policy(&self, approval_policy: &Constrained) { if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() { *policy = approval_policy.value(); @@ -148,6 +154,7 @@ impl McpConnectionManager { runtime_environment: McpRuntimeEnvironment, codex_home: PathBuf, codex_apps_tools_cache_key: CodexAppsToolsCacheKey, + host_owned_codex_apps_enabled: bool, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, ) -> (Self, CancellationToken) { @@ -248,6 +255,7 @@ impl McpConnectionManager { let manager = Self { clients, server_origins, + host_owned_codex_apps_enabled, elicitation_requests: elicitation_requests.clone(), startup_cancellation_token: cancel_token.clone(), }; diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 9d4ee60e89..9b11cbaec1 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -8,11 +8,21 @@ pub use mcp::CODEX_APPS_MCP_SERVER_NAME; pub use mcp::McpConfig; pub use mcp::ToolPluginProvenance; +pub use auth_elicitation::CodexAppsAuthElicitation; +pub use auth_elicitation::CodexAppsAuthElicitationPlan; +pub use auth_elicitation::CodexAppsConnectorAuthFailure; +pub use auth_elicitation::MCP_TOOL_CODEX_APPS_META_KEY; +pub use auth_elicitation::auth_elicitation_completed_result; +pub use auth_elicitation::auth_elicitation_id; +pub use auth_elicitation::build_auth_elicitation; +pub use auth_elicitation::build_auth_elicitation_plan; +pub use auth_elicitation::connector_auth_failure_from_tool_result; pub use codex_apps::CodexAppsToolsCacheKey; pub use codex_apps::codex_apps_tools_cache_key; pub use mcp::configured_mcp_servers; pub use mcp::effective_mcp_servers; +pub use mcp::host_owned_codex_apps_enabled; pub use mcp::tool_plugin_provenance; pub use mcp::with_codex_apps_mcp; @@ -39,6 +49,7 @@ pub use mcp::mcp_permission_prompt_is_auto_approved; pub use mcp::qualified_mcp_tool_name_prefix; pub use tools::declared_openai_file_input_param_names; +pub(crate) mod auth_elicitation; pub(crate) mod codex_apps; pub(crate) mod connection_manager; pub(crate) mod elicitation; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index d689ea9043..2fdbb7ccd4 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -198,7 +198,7 @@ pub fn with_codex_apps_mcp( auth: Option<&CodexAuth>, config: &McpConfig, ) -> HashMap { - if config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) { + if host_owned_codex_apps_enabled(config, auth) { servers.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), codex_apps_mcp_server_config(config), @@ -209,6 +209,10 @@ pub fn with_codex_apps_mcp( servers } +pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool { + config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) +} + pub fn configured_mcp_servers(config: &McpConfig) -> HashMap { config.configured_mcp_servers.clone() } @@ -233,6 +237,7 @@ pub async fn read_mcp_resource( uri: &str, ) -> anyhow::Result { let mut mcp_servers = effective_mcp_servers(config, auth); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); mcp_servers.retain(|name, _| name == server); let auth_statuses = compute_auth_statuses( mcp_servers.iter(), @@ -253,6 +258,7 @@ pub async fn read_mcp_resource( runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, tool_plugin_provenance(config), auth, ) @@ -287,6 +293,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { let mcp_servers = effective_mcp_servers(config, auth); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { @@ -318,6 +325,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, tool_plugin_provenance, auth, ) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index c40b7654ab..57d3d276b2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -370,6 +370,9 @@ "apps_mcp_path_override": { "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" }, + "auth_elicitation": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, @@ -3919,6 +3922,9 @@ "apps_mcp_path_override": { "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" }, + "auth_elicitation": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 9f0381f53a..e2154db17e 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -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(), ) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 91e2e079ab..a6557b3d67 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -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, + metadata: Option<&McpToolApprovalMetadata>, request_meta: Option, ) -> Result { 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>, } -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"; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index a6818579a6..225ee0cf70 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -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!( diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 89c368d0c2..38ade77ca3 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -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(), ) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 948678067e..518dacde58 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -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; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index d593544b49..92a27b284f 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -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, ) diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 5b83d3b136..df042029ae 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -74,6 +74,7 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) { "mcpServers": {{ "sample": {{ "command": "{command}", + "cwd": ".", "startup_timeout_sec": 60.0 }} }} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index acb6fa195e..dfacc0d55b 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -204,6 +204,8 @@ pub enum Feature { CollaborationModes, /// Route MCP tool approval prompts through the MCP elicitation request path. ToolCallMcpElicitation, + /// Prompt Codex Apps connector auth failures through MCP URL elicitations. + AuthElicitation, /// Enable personality selection in the TUI. Personality, /// Enable native artifact tools. @@ -1045,6 +1047,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::AuthElicitation, + key: "auth_elicitation", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Personality, key: "personality", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index da0e36442c..4a258141b5 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -247,6 +247,16 @@ fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), true); } +#[test] +fn auth_elicitation_is_under_development() { + assert_eq!(Feature::AuthElicitation.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::AuthElicitation.default_enabled(), false); + assert_eq!( + feature_for_key("auth_elicitation"), + Some(Feature::AuthElicitation) + ); +} + #[test] fn remote_control_is_under_development() { assert_eq!(Feature::RemoteControl.stage(), Stage::UnderDevelopment); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0ecbd02d61..5e90bb863b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -17,6 +17,7 @@ use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; use crate::app_server_session::app_server_rate_limit_snapshots; +use crate::bottom_pane::AppLinkViewParams; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::McpServerElicitationFormRequest; @@ -219,6 +220,7 @@ const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue." const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; enum ThreadInteractiveRequest { + AppLink(AppLinkViewParams), Approval(ApprovalRequest), McpServerElicitation(McpServerElicitationFormRequest), } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index bb00b53533..3f061fa516 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -37,6 +37,8 @@ use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileUpdateChange; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; @@ -2755,6 +2757,84 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( ); } +#[tokio::test] +async fn inactive_thread_url_elicitation_routes_to_app_link() { + let app = make_test_app().await; + let thread_id = ThreadId::new(); + let request = ServerRequest::McpServerElicitationRequest { + request_id: AppServerRequestId::Integer(9), + params: McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + }, + }; + + let Some(ThreadInteractiveRequest::AppLink(params)) = app + .interactive_request_for_thread_request(thread_id, &request) + .await + else { + panic!("expected app link request"); + }; + + assert_eq!(params.title, "Action required"); + assert_eq!(params.description, Some("Server: payments".to_string())); + assert_eq!(params.url, "https://payments.example/checkout/123"); + assert_eq!( + params.elicitation_target, + Some(crate::bottom_pane::AppLinkElicitationTarget { + thread_id, + server_name: "payments".to_string(), + request_id: AppServerRequestId::Integer(9), + }) + ); +} + +#[tokio::test] +async fn inactive_thread_invalid_url_elicitation_is_declined() { + let (app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let request = ServerRequest::McpServerElicitationRequest { + request_id: AppServerRequestId::Integer(10), + params: McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "http://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + }, + }; + + assert!( + app.interactive_request_for_thread_request(thread_id, &request) + .await + .is_none() + ); + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::SubmitThreadOp { + thread_id: op_thread_id, + op: Op::ResolveElicitation { + server_name, + request_id: AppServerRequestId::Integer(10), + decision: codex_app_server_protocol::McpServerElicitationAction::Decline, + content: None, + meta: None, + }, + }) if op_thread_id == thread_id && server_name == "payments" + ); +} + #[tokio::test] async fn inactive_thread_approval_badge_clears_after_turn_completion_notification() -> Result<()> { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index df6f01e8bd..1915cc7683 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -262,31 +262,47 @@ impl App { }), ), ServerRequest::McpServerElicitationRequest { request_id, params } => { - if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( + if let Some(params) = AppLinkViewParams::from_url_app_server_request( thread_id, + ¶ms.server_name, request_id.clone(), - params.clone(), + ¶ms.request, ) { + Some(ThreadInteractiveRequest::AppLink(params)) + } else if let Some(request) = + McpServerElicitationFormRequest::from_app_server_request( + thread_id, + request_id.clone(), + params.clone(), + ) + { Some(ThreadInteractiveRequest::McpServerElicitation(request)) } else { - Some(ThreadInteractiveRequest::Approval( - ApprovalRequest::McpElicitation { - thread_id, - thread_label, - server_name: params.server_name.clone(), - request_id: request_id.clone(), - message: match ¶ms.request { - codex_app_server_protocol::McpServerElicitationRequest::Form { - message, - .. - } - | codex_app_server_protocol::McpServerElicitationRequest::Url { - message, - .. - } => message.clone(), + match ¶ms.request { + codex_app_server_protocol::McpServerElicitationRequest::Form { + message, + .. + } => Some(ThreadInteractiveRequest::Approval( + ApprovalRequest::McpElicitation { + thread_id, + thread_label, + server_name: params.server_name.clone(), + request_id: request_id.clone(), + message: message.clone(), }, - }, - )) + )), + codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { + self.app_event_tx.resolve_elicitation( + thread_id, + params.server_name.clone(), + request_id.clone(), + codex_app_server_protocol::McpServerElicitationAction::Decline, + /*content*/ None, + /*meta*/ None, + ); + None + } + } } } ServerRequest::PermissionsRequestApproval { params, .. } => Some( @@ -304,6 +320,9 @@ impl App { pub(super) fn push_thread_interactive_request(&mut self, request: ThreadInteractiveRequest) { match request { + ThreadInteractiveRequest::AppLink(params) => { + self.chat_widget.open_app_link_view(params); + } ThreadInteractiveRequest::Approval(request) => { self.render_inactive_patch_preview(&request); self.chat_widget.push_approval_request(request); diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 43ff94618d..3702849fce 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -17,6 +17,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::Wrap; use textwrap::wrap; +use url::Url; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -34,6 +35,13 @@ use crate::style::user_message_style; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; +const MCP_CODEX_APPS_SERVER_NAME: &str = "codex_apps"; +const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure"; +const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure"; +const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id"; +const CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY: &str = "connector_name"; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AppLinkScreen { Link, @@ -44,6 +52,8 @@ enum AppLinkScreen { pub(crate) enum AppLinkSuggestionType { Install, Enable, + Auth, + ExternalAction, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -53,6 +63,7 @@ pub(crate) struct AppLinkElicitationTarget { pub(crate) request_id: AppServerRequestId, } +#[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AppLinkViewParams { pub(crate) app_id: String, pub(crate) title: String, @@ -66,6 +77,152 @@ pub(crate) struct AppLinkViewParams { pub(crate) elicitation_target: Option, } +impl AppLinkViewParams { + pub(crate) fn from_url_app_server_request( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + request: &codex_app_server_protocol::McpServerElicitationRequest, + ) -> Option { + let codex_app_server_protocol::McpServerElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + } = request + else { + return None; + }; + if server_name == MCP_CODEX_APPS_SERVER_NAME { + let url = validate_external_url(url, /*require_chatgpt_host*/ true)?; + return Self::from_codex_apps_auth_url_parts( + thread_id, + server_name, + request_id, + meta.as_ref(), + message, + url.as_str(), + elicitation_id, + ); + } + + let url = validate_external_url(url, /*require_chatgpt_host*/ false)?; + Some(Self::from_generic_url_parts( + thread_id, + server_name, + request_id, + message, + url.as_str(), + elicitation_id, + )) + } + + fn from_codex_apps_auth_url_parts( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + meta: Option<&serde_json::Value>, + message: &str, + url: &str, + elicitation_id: &str, + ) -> Option { + let auth_failure = meta? + .as_object()? + .get(MCP_TOOL_CODEX_APPS_META_KEY)? + .as_object()? + .get(CONNECTOR_AUTH_FAILURE_META_KEY)? + .as_object()?; + if auth_failure + .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY) + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } + + let app_id = auth_failure + .get(CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(elicitation_id) + .to_string(); + let title = auth_failure + .get(CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(app_id.as_str()) + .to_string(); + + Some(Self { + app_id, + title, + description: None, + instructions: "Sign in to this app in your browser, then return here.".to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some(message.to_string()), + suggestion_type: Some(AppLinkSuggestionType::Auth), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id, + server_name: server_name.to_string(), + request_id, + }), + }) + } + + fn from_generic_url_parts( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + message: &str, + url: &str, + elicitation_id: &str, + ) -> Self { + Self { + app_id: elicitation_id.to_string(), + title: "Action required".to_string(), + description: Some(format!("Server: {server_name}")), + instructions: "Complete the requested action in your browser, then return here." + .to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some(message.to_string()), + suggestion_type: Some(AppLinkSuggestionType::ExternalAction), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id, + server_name: server_name.to_string(), + request_id, + }), + } + } +} + +fn validate_external_url(url: &str, require_chatgpt_host: bool) -> Option { + let parsed = Url::parse(url).ok()?; + if parsed.scheme() != "https" || parsed.host_str().is_none() { + return None; + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return None; + } + if require_chatgpt_host && !is_allowed_chatgpt_auth_host(parsed.host_str()?) { + return None; + } + Some(parsed) +} + +fn is_allowed_chatgpt_auth_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + host == "chatgpt.com" + || host == "chatgpt-staging.com" + || host.ends_with(".chatgpt.com") + || host.ends_with(".chatgpt-staging.com") +} + pub(crate) struct AppLinkView { app_id: String, title: String, @@ -116,6 +273,19 @@ impl AppLinkView { } fn action_labels(&self) -> Vec<&'static str> { + if self.is_auth_suggestion() { + return match self.screen { + AppLinkScreen::Link => vec!["Open sign-in URL", "Back"], + AppLinkScreen::InstallConfirmation => vec!["I already signed in", "Back"], + }; + } + if self.is_external_action_suggestion() { + return match self.screen { + AppLinkScreen::Link => vec!["Open link", "Back"], + AppLinkScreen::InstallConfirmation => vec!["I finished", "Back"], + }; + } + match self.screen { AppLinkScreen::Link => { if self.is_installed { @@ -148,6 +318,19 @@ impl AppLinkView { self.elicitation_target.is_some() } + fn is_auth_suggestion(&self) -> bool { + self.is_tool_suggestion() && self.suggestion_type == Some(AppLinkSuggestionType::Auth) + } + + fn is_external_action_suggestion(&self) -> bool { + self.is_tool_suggestion() + && self.suggestion_type == Some(AppLinkSuggestionType::ExternalAction) + } + + fn is_browser_action_suggestion(&self) -> bool { + self.is_auth_suggestion() || self.is_external_action_suggestion() + } + fn resolve_elicitation(&self, decision: McpServerElicitationAction) { let Some(target) = self.elicitation_target.as_ref() else { return; @@ -167,20 +350,26 @@ impl AppLinkView { self.complete = true; } - fn open_chatgpt_link(&mut self) { + fn open_external_url(&mut self) { self.app_event_tx.send(AppEvent::OpenUrlInBrowser { url: self.url.clone(), }); - if !self.is_installed { + if !self.is_installed || self.is_browser_action_suggestion() { self.screen = AppLinkScreen::InstallConfirmation; self.selected_action = 0; } } - fn refresh_connectors_and_close(&mut self) { - self.app_event_tx.send(AppEvent::RefreshConnectors { - force_refetch: true, - }); + fn complete_external_flow_and_close(&mut self) { + let should_refresh_connectors = self + .elicitation_target + .as_ref() + .is_none_or(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME); + if should_refresh_connectors { + self.app_event_tx.send(AppEvent::RefreshConnectors { + force_refetch: true, + }); + } if self.is_tool_suggestion() { self.resolve_elicitation(McpServerElicitationAction::Accept); } @@ -209,22 +398,42 @@ impl AppLinkView { match self.suggestion_type { Some(AppLinkSuggestionType::Enable) => match self.screen { AppLinkScreen::Link => match self.selected_action { - 0 => self.open_chatgpt_link(), + 0 => self.open_external_url(), 1 if self.is_installed => self.toggle_enabled(), _ => self.decline_tool_suggestion(), }, AppLinkScreen::InstallConfirmation => match self.selected_action { - 0 => self.refresh_connectors_and_close(), + 0 => self.complete_external_flow_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::Auth) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::ExternalAction) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), _ => self.decline_tool_suggestion(), }, }, Some(AppLinkSuggestionType::Install) | None => match self.screen { AppLinkScreen::Link => match self.selected_action { - 0 => self.open_chatgpt_link(), + 0 => self.open_external_url(), _ => self.decline_tool_suggestion(), }, AppLinkScreen::InstallConfirmation => match self.selected_action { - 0 => self.refresh_connectors_and_close(), + 0 => self.complete_external_flow_and_close(), _ => self.decline_tool_suggestion(), }, }, @@ -234,12 +443,12 @@ impl AppLinkView { match self.screen { AppLinkScreen::Link => match self.selected_action { - 0 => self.open_chatgpt_link(), + 0 => self.open_external_url(), 1 if self.is_installed => self.toggle_enabled(), _ => self.complete = true, }, AppLinkScreen::InstallConfirmation => match self.selected_action { - 0 => self.refresh_connectors_and_close(), + 0 => self.complete_external_flow_and_close(), _ => self.back_to_link_screen(), }, } @@ -280,31 +489,42 @@ impl AppLinkView { } lines.push(Line::from("")); } - if self.is_installed { + let is_browser_action_suggestion = self.is_browser_action_suggestion(); + if self.is_installed && !is_browser_action_suggestion { for line in wrap("Use $ to insert this app into the prompt.", usable_width) { lines.push(Line::from(line.into_owned())); } lines.push(Line::from("")); } + if is_browser_action_suggestion { + lines.push(Line::from("URL".dim())); + for line in wrap(&self.url, usable_width) { + lines.push(Line::from(line.into_owned())); + } + lines.push(Line::from("")); + } + let instructions = self.instructions.trim(); if !instructions.is_empty() { for line in wrap(instructions, usable_width) { lines.push(Line::from(line.into_owned())); } - for line in wrap( - "Newly installed apps can take a few minutes to appear in /apps.", - usable_width, - ) { - lines.push(Line::from(line.into_owned())); - } - if !self.is_installed { + if !is_browser_action_suggestion { for line in wrap( - "After installed, use $ to insert this app into the prompt.", + "Newly installed apps can take a few minutes to appear in /apps.", usable_width, ) { lines.push(Line::from(line.into_owned())); } + if !self.is_installed { + for line in wrap( + "After installed, use $ to insert this app into the prompt.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } } lines.push(Line::from("")); } @@ -316,24 +536,82 @@ impl AppLinkView { let usable_width = width.max(1) as usize; let mut lines: Vec> = Vec::new(); - lines.push(Line::from("Finish App Setup".bold())); + let is_auth_suggestion = self.is_auth_suggestion(); + let is_external_action_suggestion = self.is_external_action_suggestion(); + let is_codex_apps_auth = is_auth_suggestion + && self + .elicitation_target + .as_ref() + .is_some_and(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME); + lines.push(Line::from( + if is_auth_suggestion { + if is_codex_apps_auth { + "Finish App Sign In" + } else { + "Finish Authentication" + } + } else if is_external_action_suggestion { + "Finish in Browser" + } else { + "Finish App Setup" + } + .bold(), + )); lines.push(Line::from("")); - for line in wrap( - "Complete app setup on ChatGPT in the browser window that just opened.", - usable_width, - ) { - lines.push(Line::from(line.into_owned())); - } - for line in wrap( - "Sign in there if needed, then return here and select \"I already Installed it\".", - usable_width, - ) { - lines.push(Line::from(line.into_owned())); + if is_auth_suggestion { + for line in wrap( + if is_codex_apps_auth { + "Sign in to the app on ChatGPT in the browser window that just opened." + } else { + "Complete authentication in the browser window that just opened." + }, + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Then return here and select \"I already signed in\".", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } else if is_external_action_suggestion { + for line in wrap( + "Complete the requested action in the browser window that just opened.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap("Then return here and select \"I finished\".", usable_width) { + lines.push(Line::from(line.into_owned())); + } + } else { + for line in wrap( + "Complete app setup on ChatGPT in the browser window that just opened.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Sign in there if needed, then return here and select \"I already Installed it\".", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } } lines.push(Line::from("")); - lines.push(Line::from(vec!["Setup URL:".dim()])); + lines.push(Line::from(vec![ + if is_auth_suggestion { + "Sign-in URL:" + } else if is_external_action_suggestion { + "Link:" + } else { + "Setup URL:" + } + .dim(), + ])); let url_line = Line::from(vec![self.url.clone().cyan().underlined()]); lines.extend(adaptive_wrap_lines( vec![url_line], @@ -586,6 +864,135 @@ mod tests { } } + fn generic_url_target() -> AppLinkElicitationTarget { + AppLinkElicitationTarget { + thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002") + .expect("valid thread id"), + server_name: "payments".to_string(), + request_id: AppServerRequestId::String("request-2".to_string()), + } + } + + fn auth_url_request(url: &str) -> codex_app_server_protocol::McpServerElicitationRequest { + codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: Some(serde_json::json!({ + "_codex_apps": { + "connector_auth_failure": { + "is_auth_failure": true, + "connector_id": "connector_calendar", + "connector_name": "Google Calendar", + }, + }, + })), + message: "Reconnect Google Calendar on ChatGPT.".to_string(), + url: url.to_string(), + elicitation_id: "codex_apps_auth_call_123".to_string(), + } + } + + #[test] + fn codex_apps_auth_url_elicitation_builds_auth_app_link_params() { + let target = suggestion_target(); + let request = + auth_url_request("https://chatgpt.com/apps/google-calendar/connector_calendar"); + + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected auth app link params"); + + assert_eq!(params.app_id, "connector_calendar"); + assert_eq!(params.title, "Google Calendar"); + assert_eq!( + params.url, + "https://chatgpt.com/apps/google-calendar/connector_calendar" + ); + assert_eq!(params.suggestion_type, Some(AppLinkSuggestionType::Auth)); + assert_eq!(params.elicitation_target, Some(target)); + } + + #[test] + fn non_codex_apps_url_elicitation_builds_generic_app_link_params() { + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + + assert_eq!( + params, + AppLinkViewParams { + app_id: "payment-123".to_string(), + title: "Action required".to_string(), + description: Some("Server: payments".to_string()), + instructions: "Complete the requested action in your browser, then return here." + .to_string(), + url: "https://payments.example/checkout/123".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some("Review the payment details to continue.".to_string()), + suggestion_type: Some(AppLinkSuggestionType::ExternalAction), + elicitation_target: Some(target), + } + ); + } + + #[test] + fn codex_apps_auth_url_elicitation_rejects_untrusted_urls() { + let target = suggestion_target(); + for url in [ + "http://chatgpt.com/apps/google-calendar/connector_calendar", + "https://user:pass@chatgpt.com/apps/google-calendar/connector_calendar", + "https://chatgpt.com.evil.example/apps/google-calendar/connector_calendar", + "https://evilchatgpt.com/apps/google-calendar/connector_calendar", + ] { + let request = auth_url_request(url); + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ); + assert!(params.is_none(), "expected {url} to be rejected"); + } + } + + #[test] + fn generic_url_elicitation_rejects_untrusted_urls() { + let target = generic_url_target(); + for url in [ + "http://payments.example/checkout/123", + "https://user:pass@payments.example/checkout/123", + ] { + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: url.to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ); + assert!(params.is_none(), "expected {url} to be rejected"); + } + } + fn render_snapshot(view: &AppLinkView, area: Rect) -> String { let mut buf = Buffer::empty(area); view.render(area, &mut buf); @@ -717,6 +1124,58 @@ mod tests { ); } + #[test] + fn generic_url_elicitation_resolves_without_connector_refresh() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let mut view = AppLinkView::new(params, tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::OpenUrlInBrowser { url }) => { + assert_eq!(url, "https://payments.example/checkout/123"); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert_eq!(view.screen, AppLinkScreen::InstallConfirmation); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, target.thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "payments".to_string(), + request_id: AppServerRequestId::String("request-2".to_string()), + decision: McpServerElicitationAction::Accept, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(rx.try_recv().is_err()); + assert!(view.is_complete()); + } + #[test] fn install_confirmation_does_not_split_long_url_like_token_without_scheme() { let (tx_raw, _rx) = unbounded_channel::(); @@ -1076,4 +1535,94 @@ mod tests { ) ); } + + #[test] + fn auth_suggestion_with_reason_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: None, + instructions: "Sign in to this app in your browser, then return here.".to_string(), + url: "https://chatgpt.com/apps/google-calendar/connector_google_calendar" + .to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some("Reconnect Google Calendar on ChatGPT.".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Auth), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert_snapshot!( + "app_link_view_auth_suggestion_with_reason", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } + + #[test] + fn generic_url_elicitation_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let view = AppLinkView::new(params, tx); + + assert_snapshot!( + "app_link_view_generic_url_elicitation", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } + + #[test] + fn generic_url_elicitation_confirmation_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let mut view = AppLinkView::new(params, tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_snapshot!( + "app_link_view_generic_url_elicitation_confirmation", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7b0694e0b3..29dbf59427 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1315,6 +1315,12 @@ impl BottomPane { AppLinkSuggestionType::Enable => { "Enable this app to use it for the current request.".to_string() } + AppLinkSuggestionType::Auth => unreachable!( + "auth uses URL mode elicitation, not tool suggestion forms" + ), + AppLinkSuggestionType::ExternalAction => unreachable!( + "external actions use URL mode elicitation, not tool suggestion forms" + ), }, url: install_url, is_installed, diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap new file mode 100644 index 0000000000..0cf228c129 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Google Calendar + + Reconnect Google Calendar on ChatGPT. + + URL + https://chatgpt.com/apps/google-calendar/connector_google_calendar + + Sign in to this app in your browser, then return here. + + + › 1. Open sign-in URL + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap new file mode 100644 index 0000000000..ec95ab96fa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Action required + Server: payments + + Review the payment details to continue. + + URL + https://payments.example/checkout/123 + + Complete the requested action in your browser, then return here. + + + › 1. Open link + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap new file mode 100644 index 0000000000..14236e2e76 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Finish in Browser + + Complete the requested action in the browser window that just + opened. + Then return here and select "I finished". + + Link: + https://payments.example/checkout/123 + + › 1. I finished + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0b58a913cb..377ebf3a9e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4557,7 +4557,14 @@ impl ChatWidget { }); let thread_id = self.thread_id.unwrap_or_default(); - if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( + if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request( + thread_id, + ¶ms.server_name, + request_id.clone(), + ¶ms.request, + ) { + self.open_app_link_view(params); + } else if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( thread_id, request_id.clone(), params.clone(), @@ -4565,18 +4572,29 @@ impl ChatWidget { self.bottom_pane .push_mcp_server_elicitation_request(request); } else { - let request = ApprovalRequest::McpElicitation { - thread_id, - thread_label: None, - server_name: params.server_name, - request_id, - message: match params.request { - McpServerElicitationRequest::Form { message, .. } - | McpServerElicitationRequest::Url { message, .. } => message, - }, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); + match params.request { + McpServerElicitationRequest::Form { message, .. } => { + let request = ApprovalRequest::McpElicitation { + thread_id, + thread_label: None, + server_name: params.server_name, + request_id, + message, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + } + McpServerElicitationRequest::Url { .. } => { + self.app_event_tx.resolve_elicitation( + thread_id, + params.server_name, + request_id, + codex_app_server_protocol::McpServerElicitationAction::Decline, + /*content*/ None, + /*meta*/ None, + ); + } + } } self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 3e8a0f631e..059366791e 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1,6 +1,42 @@ use super::*; use pretty_assertions::assert_eq; +#[tokio::test] +async fn invalid_url_elicitation_is_declined() { + let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + + chat.handle_elicitation_request_now( + codex_app_server_protocol::RequestId::Integer(9), + codex_app_server_protocol::McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "http://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + }, + ); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::SubmitThreadOp { + thread_id: op_thread_id, + op: Op::ResolveElicitation { + server_name, + request_id: codex_app_server_protocol::RequestId::Integer(9), + decision: codex_app_server_protocol::McpServerElicitationAction::Decline, + content: None, + meta: None, + }, + }) if op_thread_id == thread_id && server_name == "payments" + ); +} + #[tokio::test] async fn collab_spawn_end_shows_requested_model_and_effort() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;