mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
22326e263c
commit
f9a907aebe
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -74,6 +74,7 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
|
||||
"mcpServers": {{
|
||||
"sample": {{
|
||||
"command": "{command}",
|
||||
"cwd": ".",
|
||||
"startup_timeout_sec": 60.0
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+18
@@ -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
|
||||
+19
@@ -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
|
||||
+17
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user