Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 82 additions & 4 deletions codex-rs/codex-mcp/src/auth_elicitation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ 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";
const SITES_CONNECTOR_ID: &str = "connector_20205bf7d4e99a89d7154bb849718324";
const SITES_PUBLICATION_TERMS_REQUIRED_AUTH_REASON_PREFIX: &str =
"sites_publication_terms_required:";
const SITES_PUBLICATION_TERMS_REQUIRED_HTTP_STATUS_CODE: i64 = 428;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexAppsConnectorAuthFailure {
Expand All @@ -28,6 +32,23 @@ pub struct CodexAppsConnectorAuthFailure {
pub error_action: Option<String>,
}

impl CodexAppsConnectorAuthFailure {
/// Returns whether this is the versioned Sites legal disclosure that must
/// remain interactive even when ordinary approval prompts are disabled.
pub fn is_sites_publication_terms_disclosure(&self) -> bool {
self.connector_id == SITES_CONNECTOR_ID
&& self.error_http_status_code
== Some(SITES_PUBLICATION_TERMS_REQUIRED_HTTP_STATUS_CODE)
&& self
.auth_reason
.as_deref()
.and_then(|reason| {
reason.strip_prefix(SITES_PUBLICATION_TERMS_REQUIRED_AUTH_REASON_PREFIX)
})
.is_some_and(|version| !version.trim().is_empty())
}
}

#[derive(Debug, Clone, PartialEq)]
pub struct CodexAppsAuthElicitation {
pub meta: serde_json::Value,
Expand Down Expand Up @@ -167,13 +188,18 @@ pub fn auth_elicitation_completed_result(
auth_failure: &CodexAppsConnectorAuthFailure,
meta: Option<serde_json::Value>,
) -> CallToolResult {
let text = if auth_failure.is_sites_publication_terms_disclosure() {
"The ChatGPT Sites Terms were accepted. Retry this tool call now.".to_string()
} else {
format!(
"Authentication for {} was requested and accepted. Retry this tool call now.",
auth_failure.connector_name
)
};
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
),
"text": text,
})],
structured_content: None,
is_error: Some(true),
Expand All @@ -198,6 +224,9 @@ fn string_auth_failure_field(
}

fn auth_elicitation_message(auth_failure: &CodexAppsConnectorAuthFailure) -> String {
if auth_failure.is_sites_publication_terms_disclosure() {
return "Review the ChatGPT Sites Terms to continue.".to_string();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use terms-specific UI for Sites disclosure

For the TUI path, this new Sites case only changes the elicitation message, but AppLinkView::from_codex_apps_auth_url_parts still classifies every Codex Apps auth failure as Auth and hardcodes sign-in instructions/actions (app_link_view.rs shows “Sign in…” plus “Open sign-in URL” / “I already signed in”). When Sites publication terms are required under Never, users are prompted with a sign-in flow rather than terms-review copy, so add a terms-specific kind/metadata or route it through the external-action UI.

Useful? React with 👍 / 👎.

}
match auth_failure.auth_reason.as_deref() {
Some("oauth_upgrade_required") => format!(
"Reconnect {} on ChatGPT to grant the permissions needed for this request.",
Expand Down Expand Up @@ -344,4 +373,53 @@ mod tests {
assert_eq!(plan.auth_failure.connector_name, "Google Calendar");
assert_eq!(plan.elicitation.elicitation_id, "codex_apps_auth_call_123");
}

#[test]
fn identifies_only_versioned_sites_publication_terms_disclosures() {
let mut wrong_status = auth_failure(
"connector_20205bf7d4e99a89d7154bb849718324",
"sites_publication_terms_required:2026-06-12",
);
wrong_status.error_http_status_code = Some(401);

assert_eq!(
[
auth_failure(
"connector_20205bf7d4e99a89d7154bb849718324",
"sites_publication_terms_required:2026-06-12",
)
.is_sites_publication_terms_disclosure(),
auth_failure(
"connector_calendar",
"sites_publication_terms_required:2026-06-12",
)
.is_sites_publication_terms_disclosure(),
auth_failure(
"connector_20205bf7d4e99a89d7154bb849718324",
"sites_publication_terms_required:",
)
.is_sites_publication_terms_disclosure(),
auth_failure(
"connector_20205bf7d4e99a89d7154bb849718324",
"reauthentication_required",
)
.is_sites_publication_terms_disclosure(),
wrong_status.is_sites_publication_terms_disclosure(),
],
[true, false, false, false, false],
);
}

fn auth_failure(connector_id: &str, auth_reason: &str) -> CodexAppsConnectorAuthFailure {
CodexAppsConnectorAuthFailure {
connector_id: connector_id.to_string(),
connector_name: "Sites".to_string(),
install_url: "https://chatgpt.com/apps/sites".to_string(),
auth_reason: Some(auth_reason.to_string()),
link_id: None,
error_code: None,
error_http_status_code: Some(SITES_PUBLICATION_TERMS_REQUIRED_HTTP_STATUS_CODE),
error_action: None,
}
}
}
25 changes: 14 additions & 11 deletions codex-rs/core/src/mcp_tool_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -615,17 +615,6 @@ async fn maybe_request_codex_apps_auth_elicitation(
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| {
Expand All @@ -640,6 +629,20 @@ async fn maybe_request_codex_apps_auth_elicitation(
return result;
};

match turn_context.approval_policy.value() {
AskForApproval::Never if !plan.auth_failure.is_sites_publication_terms_disclosure() => {
return result;
}
AskForApproval::Granular(granular_config) if !granular_config.allows_mcp_elicitations() => {
return result;
}
AskForApproval::Never

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require a visible response before accepting Sites terms

For app-server clients with mcp_elicitations_auto_deny (Xcode 26.4), request_mcp_server_elicitation auto-returns Accept without an event. This Never path tells the model Sites terms were accepted without showing the disclosure; require sent before accepting. guidance

Useful? React with 👍 / 👎.

| AskForApproval::OnFailure
| AskForApproval::OnRequest
| AskForApproval::UnlessTrusted
| AskForApproval::Granular(_) => {}
}

let request_id = rmcp::model::RequestId::String(plan.elicitation.elicitation_id.clone().into());
let params = McpServerElicitationRequestParams {
thread_id: sess.thread_id.to_string(),
Expand Down
143 changes: 102 additions & 41 deletions codex-rs/core/src/mcp_tool_call_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,26 @@ async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_cod
}

fn codex_apps_auth_failure_result() -> CallToolResult {
codex_apps_auth_failure_result_for(
"connector_calendar",
"reauthentication_required",
/*error_http_status_code*/ 401,
)
}

fn codex_apps_sites_publication_terms_failure_result() -> CallToolResult {
codex_apps_auth_failure_result_for(
"connector_20205bf7d4e99a89d7154bb849718324",
"sites_publication_terms_required:2026-06-12",
/*error_http_status_code*/ 428,
)
}

fn codex_apps_auth_failure_result_for(
connector_id: &str,
auth_reason: &str,
error_http_status_code: i64,
) -> CallToolResult {
CallToolResult {
content: vec![serde_json::json!({
"type": "text",
Expand All @@ -1232,12 +1252,12 @@ fn codex_apps_auth_failure_result() -> CallToolResult {
MCP_TOOL_CODEX_APPS_META_KEY: {
"connector_auth_failure": {
"is_auth_failure": true,
"auth_reason": "reauthentication_required",
"connector_id": "connector_calendar",
"auth_reason": auth_reason,
"connector_id": connector_id,
"connector_name": "Untrusted Calendar",
"link_id": "link_123",
"error_code": "UNAUTHORIZED",
"error_http_status_code": 401,
"error_http_status_code": error_http_status_code,
"error_action": "TRIGGER_REAUTHENTICATION",
},
},
Expand All @@ -1255,6 +1275,16 @@ fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata {
)
}

fn codex_apps_sites_publication_terms_metadata() -> McpToolApprovalMetadata {
approval_metadata(
Some("connector_20205bf7d4e99a89d7154bb849718324"),
Some("Sites"),
Some("Create and publish websites."),
Some("Create Site"),
Some("Create a new website."),
)
}

async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) {
let auth = session.services.auth_manager.auth().await;
let manager = codex_mcp::McpConnectionManager::new(
Expand Down Expand Up @@ -1334,36 +1364,57 @@ async fn codex_apps_auth_elicitation_non_host_owned_server_returns_original_resu
}

#[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();
async fn non_sites_invocation_cannot_forge_sites_terms_elicitation_under_never_policy() {
assert_codex_apps_auth_elicitation_not_requested(
AskForApproval::Never,
codex_apps_sites_publication_terms_failure_result(),
codex_apps_auth_failure_metadata(),
)
.await;
}

let returned = maybe_request_codex_apps_auth_elicitation(
&session,
turn_context,
"call_123",
CODEX_APPS_MCP_SERVER_NAME,
Some(&metadata),
result.clone(),
#[tokio::test]
async fn codex_apps_auth_elicitation_is_disallowed_by_never_policy() {
assert_codex_apps_auth_elicitation_not_requested(
AskForApproval::Never,
codex_apps_auth_failure_result(),
codex_apps_auth_failure_metadata(),
)
.await;
}

assert_eq!(returned, result);
assert!(rx_event.try_recv().is_err());
#[tokio::test]
async fn codex_apps_sites_publication_terms_elicitation_is_allowed_by_never_policy() {
assert_codex_apps_auth_elicitation_requested(
AskForApproval::Never,
codex_apps_sites_publication_terms_failure_result(),
codex_apps_sites_publication_terms_metadata(),
"The ChatGPT Sites Terms were accepted. Retry this tool call now.",
)
.await;
}

#[tokio::test]
async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_result() {
async fn codex_apps_sites_terms_elicitation_respects_granular_mcp_disabled() {
assert_codex_apps_auth_elicitation_not_requested(
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: false,
}),
codex_apps_sites_publication_terms_failure_result(),
codex_apps_sites_publication_terms_metadata(),
)
.await;
}

async fn assert_codex_apps_auth_elicitation_not_requested(
approval_policy: AskForApproval,
result: CallToolResult,
metadata: McpToolApprovalMetadata,
) {
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();
Expand All @@ -1372,16 +1423,8 @@ async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_resu
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,
}))
.set(approval_policy)
.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,
Expand All @@ -1399,16 +1442,34 @@ async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_resu

#[tokio::test]
async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() {
assert_codex_apps_auth_elicitation_requested(
AskForApproval::OnRequest,
codex_apps_auth_failure_result(),
codex_apps_auth_failure_metadata(),
"Authentication for Google Calendar was requested and accepted. Retry this tool call now.",
)
.await;
}

async fn assert_codex_apps_auth_elicitation_requested(
approval_policy: AskForApproval,
result: CallToolResult,
metadata: McpToolApprovalMetadata,
completion_text: &str,
) {
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 turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref");
turn_context.features = ManagedFeatures::from(features);
turn_context
.approval_policy
.set(approval_policy)
.expect("test setup should allow updating approval policy");
}

let request_task = tokio::spawn({
let session = Arc::clone(&session);
Expand Down Expand Up @@ -1465,7 +1526,7 @@ async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() {
returned.content,
vec![serde_json::json!({
"type": "text",
"text": "Authentication for Google Calendar was requested and accepted. Retry this tool call now.",
"text": completion_text,
})]
);
}
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/tests/suite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ mod search_tool;
mod shell_command;
mod shell_serialization;
mod shell_snapshot;
mod sites_publication_terms;
mod skill_approval;
mod skills;
mod spawn_agent_description;
Expand Down
Loading
Loading