mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
8335b56c33
## Summary - Add `list_available_plugins_to_install` as the inventory step for plugin and connector install suggestions. - Slim `request_plugin_install` so it only handles the actual elicitation, instead of carrying the full discoverable list in its prompt. - Emit send-time telemetry when an install elicitation is dispatched, including requested tool identity in the event payload. - Emit install-result telemetry through `SessionTelemetry`, including tool type, user response action, and completion status. - Update registration and tests to cover the new two-step flow while keeping the existing `tool_suggest` feature gate unchanged. ## Testing - `just fmt` - `cargo test -p codex-tools` - `cargo test -p codex-core request_plugin_install` - `cargo test -p codex-core list_available_plugins_to_install` - `cargo test -p codex-core install_suggestion_tools_can_be_registered_without_search_tool` - `cargo test -p codex-otel manager_records_plugin_install_suggestion_metric` - `cargo test -p codex-otel manager_records_plugin_install_elicitation_sent_metric` - `just fix -p codex-core` - `just fix -p codex-tools` - `just fix -p codex-otel` - `cargo check -p codex-core`
270 lines
8.9 KiB
Rust
270 lines
8.9 KiB
Rust
use super::*;
|
|
use rmcp::model::BooleanSchema;
|
|
use rmcp::model::ElicitationSchema;
|
|
use rmcp::model::PrimitiveSchema;
|
|
use serde_json::json;
|
|
|
|
fn meta(value: Value) -> Option<Meta> {
|
|
let Value::Object(map) = value else {
|
|
panic!("metadata must be an object");
|
|
};
|
|
Some(Meta(map))
|
|
}
|
|
|
|
fn guardian_meta(tool_params: Option<Value>) -> Option<Meta> {
|
|
let mut value = json!({
|
|
"codex_approval_kind": "mcp_tool_call",
|
|
"codex_request_type": "approval_request",
|
|
"connector_id": "browser-use",
|
|
"connector_name": "Browser Use",
|
|
"tool_name": "access_browser_origin",
|
|
"tool_title": "Access browser origin",
|
|
});
|
|
if let Some(tool_params) = tool_params {
|
|
value["tool_params"] = tool_params;
|
|
}
|
|
meta(value)
|
|
}
|
|
|
|
fn form_request(meta: Option<Meta>) -> ElicitationReviewRequest {
|
|
ElicitationReviewRequest {
|
|
server_name: "browser-use".to_string(),
|
|
request_id: rmcp::model::NumberOrString::Number(7),
|
|
elicitation: CreateElicitationRequestParams::FormElicitationParams {
|
|
meta,
|
|
message: "Allow origin?".to_string(),
|
|
requested_schema: ElicitationSchema::builder()
|
|
.build()
|
|
.expect("schema should build"),
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_elicitation_review_request_builds_mcp_tool_call() {
|
|
let request = form_request(guardian_meta(Some(json!({
|
|
"origin": "https://example.com",
|
|
}))));
|
|
|
|
let GuardianElicitationReview::ApprovalRequest(guardian_request) =
|
|
guardian_elicitation_review_request(&request)
|
|
else {
|
|
panic!("expected Guardian MCP tool call request");
|
|
};
|
|
let crate::guardian::GuardianApprovalRequest::McpToolCall {
|
|
id,
|
|
server,
|
|
tool_name,
|
|
arguments,
|
|
connector_id,
|
|
connector_name,
|
|
connector_description,
|
|
tool_title,
|
|
tool_description,
|
|
annotations,
|
|
} = *guardian_request
|
|
else {
|
|
panic!("expected Guardian MCP tool call request");
|
|
};
|
|
|
|
assert_eq!(id, "mcp_elicitation:browser-use:7");
|
|
assert_eq!(server, "browser-use");
|
|
assert_eq!(tool_name, "access_browser_origin");
|
|
assert_eq!(arguments, Some(json!({ "origin": "https://example.com" })));
|
|
assert_eq!(connector_id.as_deref(), Some("browser-use"));
|
|
assert_eq!(connector_name.as_deref(), Some("Browser Use"));
|
|
assert_eq!(connector_description, None);
|
|
assert_eq!(tool_title.as_deref(), Some("Access browser origin"));
|
|
assert_eq!(tool_description, None);
|
|
assert_eq!(annotations, None);
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_elicitation_review_request_defaults_missing_tool_params() {
|
|
let request = form_request(guardian_meta(/*tool_params*/ None));
|
|
|
|
let GuardianElicitationReview::ApprovalRequest(guardian_request) =
|
|
guardian_elicitation_review_request(&request)
|
|
else {
|
|
panic!("expected Guardian MCP tool call request");
|
|
};
|
|
let crate::guardian::GuardianApprovalRequest::McpToolCall { arguments, .. } = *guardian_request
|
|
else {
|
|
panic!("expected Guardian MCP tool call request");
|
|
};
|
|
|
|
assert_eq!(arguments, Some(json!({})));
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_install_elicitation_telemetry_metadata_requires_install_tool_suggestion() {
|
|
let event = EventMsg::ElicitationRequest(ElicitationRequestEvent {
|
|
turn_id: Some("turn-1".to_string()),
|
|
server_name: "codex_apps".to_string(),
|
|
id: codex_protocol::mcp::RequestId::String("request-1".to_string()),
|
|
request: codex_protocol::approvals::ElicitationRequest::Form {
|
|
meta: Some(json!({
|
|
"codex_approval_kind": "tool_suggestion",
|
|
"suggest_type": "install",
|
|
"tool_type": "plugin",
|
|
"tool_id": "slack@openai-curated",
|
|
"tool_name": "Slack",
|
|
})),
|
|
message: "Install Slack?".to_string(),
|
|
requested_schema: json!({
|
|
"type": "object",
|
|
"properties": {},
|
|
}),
|
|
},
|
|
});
|
|
|
|
assert_eq!(
|
|
plugin_install_elicitation_telemetry_metadata(&event),
|
|
Some(PluginInstallElicitationTelemetryMetadata {
|
|
tool_type: "plugin".to_string(),
|
|
tool_id: "slack@openai-curated".to_string(),
|
|
tool_name: "Slack".to_string(),
|
|
})
|
|
);
|
|
|
|
let enable_event = EventMsg::ElicitationRequest(ElicitationRequestEvent {
|
|
turn_id: Some("turn-1".to_string()),
|
|
server_name: "codex_apps".to_string(),
|
|
id: codex_protocol::mcp::RequestId::String("request-2".to_string()),
|
|
request: codex_protocol::approvals::ElicitationRequest::Form {
|
|
meta: Some(json!({
|
|
"codex_approval_kind": "tool_suggestion",
|
|
"suggest_type": "enable",
|
|
"tool_type": "plugin",
|
|
"tool_id": "slack@openai-curated",
|
|
"tool_name": "Slack",
|
|
})),
|
|
message: "Enable Slack?".to_string(),
|
|
requested_schema: json!({
|
|
"type": "object",
|
|
"properties": {},
|
|
}),
|
|
},
|
|
});
|
|
|
|
assert_eq!(
|
|
plugin_install_elicitation_telemetry_metadata(&enable_event),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_elicitation_review_request_requires_opt_in() {
|
|
let request = form_request(meta(json!({
|
|
"codex_approval_kind": "mcp_tool_call",
|
|
"tool_name": "access_browser_origin",
|
|
})));
|
|
|
|
assert_eq!(
|
|
guardian_elicitation_review_request(&request),
|
|
GuardianElicitationReview::NotRequested
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() {
|
|
let url_request = ElicitationReviewRequest {
|
|
server_name: "browser-use".to_string(),
|
|
request_id: rmcp::model::NumberOrString::Number(8),
|
|
elicitation: CreateElicitationRequestParams::UrlElicitationParams {
|
|
meta: guardian_meta(Some(json!({}))),
|
|
message: "Open URL".to_string(),
|
|
url: "https://example.com".to_string(),
|
|
elicitation_id: "elicit-1".to_string(),
|
|
},
|
|
};
|
|
assert!(matches!(
|
|
guardian_elicitation_review_request(&url_request),
|
|
GuardianElicitationReview::Decline(_)
|
|
));
|
|
|
|
let non_empty_schema_request = ElicitationReviewRequest {
|
|
server_name: "browser-use".to_string(),
|
|
request_id: rmcp::model::NumberOrString::Number(9),
|
|
elicitation: CreateElicitationRequestParams::FormElicitationParams {
|
|
meta: guardian_meta(Some(json!({}))),
|
|
message: "Allow origin?".to_string(),
|
|
requested_schema: ElicitationSchema::builder()
|
|
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
|
|
.build()
|
|
.expect("schema should build"),
|
|
},
|
|
};
|
|
assert!(matches!(
|
|
guardian_elicitation_review_request(&non_empty_schema_request),
|
|
GuardianElicitationReview::Decline(_)
|
|
));
|
|
|
|
let missing_tool_name_request = form_request(meta(json!({
|
|
"codex_approval_kind": "mcp_tool_call",
|
|
"codex_request_type": "approval_request",
|
|
})));
|
|
assert!(matches!(
|
|
guardian_elicitation_review_request(&missing_tool_name_request),
|
|
GuardianElicitationReview::Decline(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn guardian_decisions_map_to_elicitation_responses_without_session_state() {
|
|
assert_eq!(
|
|
mcp_elicitation_response_from_guardian_decision_parts(
|
|
ReviewDecision::Approved,
|
|
/*denial_message*/ None,
|
|
),
|
|
ElicitationResponse {
|
|
action: ElicitationAction::Accept,
|
|
content: Some(json!({})),
|
|
meta: Some(json!({
|
|
"approvals_reviewer": ApprovalsReviewer::AutoReview,
|
|
})),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
mcp_elicitation_response_from_guardian_decision_parts(
|
|
ReviewDecision::Denied,
|
|
Some("Denied by Guardian".to_string()),
|
|
),
|
|
ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: Some(json!({
|
|
"approvals_reviewer": ApprovalsReviewer::AutoReview,
|
|
"message": "Denied by Guardian",
|
|
})),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
mcp_elicitation_response_from_guardian_decision_parts(
|
|
ReviewDecision::TimedOut,
|
|
/*denial_message*/ None,
|
|
),
|
|
ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: Some(json!({
|
|
"approvals_reviewer": ApprovalsReviewer::AutoReview,
|
|
"message": crate::guardian::guardian_timeout_message(),
|
|
})),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
mcp_elicitation_response_from_guardian_decision_parts(
|
|
ReviewDecision::Abort,
|
|
/*denial_message*/ None,
|
|
),
|
|
ElicitationResponse {
|
|
action: ElicitationAction::Cancel,
|
|
content: None,
|
|
meta: Some(json!({
|
|
"approvals_reviewer": ApprovalsReviewer::AutoReview,
|
|
})),
|
|
}
|
|
);
|
|
}
|