Support Codex Apps auth elicitations (#19193)

## Summary

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

## Test plan

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

Also attempted broader local runs:

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