mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
## Why Configured MCP servers must not be able to request user verification, even when they reuse the hosted apps server name. Local verification identity selection also needs to distinguish a user's workspace memberships. ## What changed - Advertise `userVerification` only to the host-owned apps MCP server and cancel verification requests from other servers before prompting the client. - Add `CodexAuth::get_chatgpt_account_user_id()` to read the access token's opaque `chatgpt_account_user_id` only when its workspace matches the selected account. Missing or malformed claims return no identity, without falling back to another user ID or breaking ordinary authentication. ## Testing Add coverage for capability filtering, rejection of configured servers including those using the hosted apps name, and account-user identity selection with malformed or mismatched claims. Add an MCP round-trip test showing that verification waits for a client response even in full access mode, returns the supplied proof, and discards response metadata. GitOrigin-RevId: 156e83d5a0302c013b85e564b46b4bfe858cbdb0
219 lines
7.9 KiB
Rust
219 lines
7.9 KiB
Rust
//! Exercises the MCP-to-core ceremony without depending on biometric hardware.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use axum::Router;
|
|
use codex_config::Constrained;
|
|
use codex_core::StartThreadOptions;
|
|
use codex_core::config::Config;
|
|
use codex_extension_api::ExtensionFuture;
|
|
use codex_extension_api::ExtensionRegistryBuilder;
|
|
use codex_extension_api::McpServerContribution;
|
|
use codex_extension_api::McpServerContributionContext;
|
|
use codex_extension_api::McpServerContributor;
|
|
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
|
use codex_protocol::approvals::ElicitationRequest;
|
|
use codex_protocol::mcp::ClientMcpExtensions;
|
|
use codex_protocol::mcp::OPENAI_ELICITATION_EXTENSION_ID;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::ElicitationAction;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::Op;
|
|
use core_test_support::apps_test_server::apps_enabled_builder;
|
|
use core_test_support::responses::start_mock_server;
|
|
use core_test_support::wait_for_event;
|
|
use pretty_assertions::assert_eq;
|
|
use rmcp::ServerHandler;
|
|
use rmcp::model::CallToolRequestParams;
|
|
use rmcp::model::CallToolResponse;
|
|
use rmcp::model::CallToolResult;
|
|
use rmcp::model::ContentBlock;
|
|
use rmcp::model::CustomRequest;
|
|
use rmcp::model::ServerCapabilities;
|
|
use rmcp::model::ServerInfo;
|
|
use rmcp::model::ServerRequest;
|
|
use rmcp::service::RequestContext;
|
|
use rmcp::service::RoleServer;
|
|
use rmcp::transport::StreamableHttpServerConfig;
|
|
use rmcp::transport::StreamableHttpService;
|
|
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::Mutex;
|
|
use tokio::time::timeout;
|
|
use tokio_util::task::AbortOnDropHandle;
|
|
|
|
#[derive(Clone)]
|
|
struct VerificationServer {
|
|
response: Arc<Mutex<Option<Value>>>,
|
|
}
|
|
|
|
struct HostedVerificationServer(codex_config::McpServerConfig);
|
|
|
|
impl McpServerContributor<Config> for HostedVerificationServer {
|
|
fn id(&self) -> &'static str {
|
|
"user-verification-integration-test"
|
|
}
|
|
|
|
fn contribute<'a>(
|
|
&'a self,
|
|
_context: McpServerContributionContext<'a, Config>,
|
|
) -> ExtensionFuture<'a, Vec<McpServerContribution>> {
|
|
Box::pin(async move {
|
|
vec![McpServerContribution::HostedApps {
|
|
config: Box::new(self.0.clone()),
|
|
}]
|
|
})
|
|
}
|
|
}
|
|
|
|
impl ServerHandler for VerificationServer {
|
|
fn get_info(&self) -> ServerInfo {
|
|
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
|
}
|
|
|
|
async fn call_tool(
|
|
&self,
|
|
_request: CallToolRequestParams,
|
|
context: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResponse, rmcp::ErrorData> {
|
|
assert_eq!(
|
|
context
|
|
.peer
|
|
.peer_info()
|
|
.and_then(|info| info.capabilities.extensions.clone())
|
|
.and_then(|extensions| extensions.get(OPENAI_ELICITATION_EXTENSION_ID).cloned())
|
|
.map(Value::Object),
|
|
Some(json!({"userVerification": {}})),
|
|
);
|
|
let result = context
|
|
.peer
|
|
.send_request(ServerRequest::CustomRequest(CustomRequest::new(
|
|
"openai/elicitation/create",
|
|
Some(json!({
|
|
"mode": "openai/userVerification",
|
|
"title": "Approve purchase",
|
|
"description": "Pay $200 to Example Store",
|
|
"challenge": "AAECA_7_",
|
|
})),
|
|
)))
|
|
.await
|
|
.map_err(|error| {
|
|
rmcp::ErrorData::internal_error(error.to_string(), /*data*/ None)
|
|
})?;
|
|
*self.response.lock().await = Some(serde_json::to_value(result).map_err(|error| {
|
|
rmcp::ErrorData::internal_error(error.to_string(), /*data*/ None)
|
|
})?);
|
|
Ok(CallToolResult::success(vec![ContentBlock::text("verified")]).into())
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn user_verification_mcp_round_trip_requires_proof_in_full_access() -> Result<()> {
|
|
let received = Arc::new(Mutex::new(None));
|
|
let server = VerificationServer {
|
|
response: Arc::clone(&received),
|
|
};
|
|
let service = StreamableHttpService::new(
|
|
move || Ok(server.clone()),
|
|
Arc::new(LocalSessionManager::default()),
|
|
StreamableHttpServerConfig::default(),
|
|
);
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let base_url = format!("http://{}", listener.local_addr()?);
|
|
let router = Router::new().nest_service("/api/codex/ps/mcp", service);
|
|
let _server = AbortOnDropHandle::new(tokio::spawn(async move {
|
|
axum::serve(listener, router).await.unwrap();
|
|
}));
|
|
let model_server = start_mock_server().await;
|
|
let mut extensions = ExtensionRegistryBuilder::new();
|
|
let plugin_service_config = serde_json::from_value(json!({
|
|
"url": format!("{base_url}/api/codex/ps/mcp"),
|
|
}))?;
|
|
extensions.mcp_server_contributor(Arc::new(HostedVerificationServer(plugin_service_config)));
|
|
let test = apps_enabled_builder(base_url)
|
|
.with_extensions(Arc::new(extensions.build()))
|
|
.with_config(|config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
|
|
config
|
|
.permissions
|
|
.set_permission_profile(PermissionProfile::Disabled)
|
|
.unwrap();
|
|
})
|
|
.build_with_auto_env(&model_server)
|
|
.await?;
|
|
// Supply the trusted host capability through the public core API. The production
|
|
// app-server only activates it for supported in-process UIs with biometric hardware.
|
|
let thread = test
|
|
.thread_manager
|
|
.start_thread(StartThreadOptions {
|
|
client_mcp_extensions: ClientMcpExtensions::new([(
|
|
OPENAI_ELICITATION_EXTENSION_ID.to_string(),
|
|
json!({"userVerification": {}}),
|
|
)]),
|
|
environments: Some(vec![test.executor_environment().selection().clone()]),
|
|
..StartThreadOptions::new(test.config.clone())
|
|
})
|
|
.await?
|
|
.thread;
|
|
let caller = Arc::clone(&thread);
|
|
let call = AbortOnDropHandle::new(tokio::spawn(async move {
|
|
caller
|
|
.call_mcp_tool(
|
|
CODEX_APPS_MCP_SERVER_NAME,
|
|
"verify_action",
|
|
/*arguments*/ None,
|
|
/*meta*/ None,
|
|
)
|
|
.await
|
|
}));
|
|
let EventMsg::ElicitationRequest(request) = wait_for_event(&thread, |event| {
|
|
matches!(event, EventMsg::ElicitationRequest(_))
|
|
})
|
|
.await
|
|
else {
|
|
unreachable!()
|
|
};
|
|
assert_eq!(request.server_name, CODEX_APPS_MCP_SERVER_NAME);
|
|
assert_eq!(
|
|
request.request,
|
|
ElicitationRequest::UserVerification {
|
|
title: "Approve purchase".into(),
|
|
description: "Pay $200 to Example Store".into(),
|
|
challenge: "AAECA_7_".into(),
|
|
}
|
|
);
|
|
assert!(received.lock().await.is_none());
|
|
assert!(!call.is_finished());
|
|
let proof = json!({"credentialId": "AQID", "signature": "BAUG"});
|
|
thread
|
|
.submit(Op::ResolveElicitation {
|
|
server_name: request.server_name,
|
|
request_id: request.id,
|
|
decision: ElicitationAction::Accept,
|
|
content: Some(proof.clone()),
|
|
meta: Some(json!({"untrusted": "discarded"})),
|
|
})
|
|
.await?;
|
|
assert_eq!(
|
|
timeout(Duration::from_secs(/*secs*/ 10), call).await???,
|
|
codex_protocol::mcp::CallToolResult {
|
|
content: vec![json!({"type": "text", "text": "verified"})],
|
|
structured_content: None,
|
|
is_error: Some(false),
|
|
meta: None,
|
|
},
|
|
);
|
|
assert_eq!(
|
|
*received.lock().await,
|
|
Some(json!({"action": "accept", "content": proof})),
|
|
);
|
|
thread.shutdown_and_wait().await?;
|
|
test.codex.shutdown_and_wait().await?;
|
|
Ok(())
|
|
}
|