From ad5d583d50b9de16cdcd09d256bca10793fd8d8b Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Thu, 22 Jan 2026 16:35:03 -0800 Subject: [PATCH] update --- codex-rs/Cargo.lock | 4 + codex-rs/app-server/Cargo.toml | 9 + .../app-server/tests/common/auth_fixtures.rs | 34 +- .../app-server/tests/common/mcp_process.rs | 7 + .../app-server/tests/suite/v2/app_list.rs | 381 ++++++++++++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + 6 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 codex-rs/app-server/tests/suite/v2/app_list.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9f5a483cdd..7e503bc123 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -625,6 +625,8 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", + "serde_json", + "serde_path_to_error", "sync_wrapper", "tokio", "tower", @@ -1000,6 +1002,7 @@ version = "0.0.0" dependencies = [ "anyhow", "app_test_support", + "axum", "base64", "chrono", "codex-app-server-protocol", @@ -1019,6 +1022,7 @@ dependencies = [ "mcp-types", "os_info", "pretty_assertions", + "rmcp", "serde", "serde_json", "serial_test", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 96a4be568a..f31df68b3f 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -49,11 +49,20 @@ uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] app_test_support = { workspace = true } +axum = { workspace = true, default-features = false, features = [ + "http1", + "json", + "tokio", +] } base64 = { workspace = true } core_test_support = { workspace = true } mcp-types = { workspace = true } os_info = { workspace = true } pretty_assertions = { workspace = true } +rmcp = { workspace = true, default-features = false, features = [ + "server", + "transport-streamable-http-server", +] } serial_test = { workspace = true } wiremock = { workspace = true } shlex = { workspace = true } diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 071a920b89..9f1b62744a 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -49,6 +49,16 @@ impl ChatGptAuthFixture { self } + pub fn chatgpt_user_id(mut self, chatgpt_user_id: impl Into) -> Self { + self.claims.chatgpt_user_id = Some(chatgpt_user_id.into()); + self + } + + pub fn chatgpt_account_id(mut self, chatgpt_account_id: impl Into) -> Self { + self.claims.chatgpt_account_id = Some(chatgpt_account_id.into()); + self + } + pub fn email(mut self, email: impl Into) -> Self { self.claims.email = Some(email.into()); self @@ -69,6 +79,8 @@ impl ChatGptAuthFixture { pub struct ChatGptIdTokenClaims { pub email: Option, pub plan_type: Option, + pub chatgpt_user_id: Option, + pub chatgpt_account_id: Option, } impl ChatGptIdTokenClaims { @@ -85,6 +97,16 @@ impl ChatGptIdTokenClaims { self.plan_type = Some(plan_type.into()); self } + + pub fn chatgpt_user_id(mut self, chatgpt_user_id: impl Into) -> Self { + self.chatgpt_user_id = Some(chatgpt_user_id.into()); + self + } + + pub fn chatgpt_account_id(mut self, chatgpt_account_id: impl Into) -> Self { + self.chatgpt_account_id = Some(chatgpt_account_id.into()); + self + } } pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result { @@ -93,10 +115,20 @@ pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result { if let Some(email) = &claims.email { payload.insert("email".to_string(), json!(email)); } + let mut auth_payload = serde_json::Map::new(); if let Some(plan_type) = &claims.plan_type { + auth_payload.insert("chatgpt_plan_type".to_string(), json!(plan_type)); + } + if let Some(chatgpt_user_id) = &claims.chatgpt_user_id { + auth_payload.insert("chatgpt_user_id".to_string(), json!(chatgpt_user_id)); + } + if let Some(chatgpt_account_id) = &claims.chatgpt_account_id { + auth_payload.insert("chatgpt_account_id".to_string(), json!(chatgpt_account_id)); + } + if !auth_payload.is_empty() { payload.insert( "https://api.openai.com/auth".to_string(), - json!({ "chatgpt_plan_type": plan_type }), + serde_json::Value::Object(auth_payload), ); } let payload = serde_json::Value::Object(payload); diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 815aac9977..f121c883b4 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -12,6 +12,7 @@ use tokio::process::ChildStdout; use anyhow::Context; use codex_app_server_protocol::AddConversationListenerParams; +use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::ArchiveConversationParams; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginChatGptParams; @@ -399,6 +400,12 @@ impl McpProcess { self.send_request("model/list", params).await } + /// Send an `app/list` JSON-RPC request. + pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("app/list", params).await + } + /// Send a `collaborationMode/list` JSON-RPC request. pub async fn send_list_collaboration_modes_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs new file mode 100644 index 0000000000..4c3e22ffa7 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -0,0 +1,381 @@ +use std::borrow::Cow; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use axum::Json; +use axum::Router; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::http::StatusCode; +use axum::http::header::AUTHORIZATION; +use axum::routing::post; +use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppsListParams; +use codex_app_server_protocol::AppsListResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::connectors::ConnectorInfo; +use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::JsonObject; +use rmcp::model::ListToolsResult; +use rmcp::model::Meta; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use serde_json::json; +use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: Some(50), + cursor: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let AppsListResponse { data, next_cursor } = to_response(response)?; + + assert!(data.is_empty()); + assert!(next_cursor.is_none()); + Ok(()) +} + +#[tokio::test] +async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { + let connectors = vec![ + ConnectorInfo { + connector_id: "alpha".to_string(), + connector_name: "Alpha".to_string(), + connector_description: Some("Alpha connector".to_string()), + logo_url: Some("https://example.com/alpha.png".to_string()), + install_url: None, + is_accessible: false, + }, + ConnectorInfo { + connector_id: "beta".to_string(), + connector_name: "beta".to_string(), + connector_description: None, + logo_url: None, + install_url: None, + is_accessible: false, + }, + ]; + + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = start_apps_server(connectors.clone(), tools).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let AppsListResponse { data, next_cursor } = to_response(response)?; + + let expected = vec![ + AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: None, + logo_url: None, + install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), + is_accessible: true, + }, + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: Some("https://example.com/alpha.png".to_string()), + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: false, + }, + ]; + + assert_eq!(data, expected); + assert!(next_cursor.is_none()); + + server_handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn list_apps_paginates_results() -> Result<()> { + let connectors = vec![ + ConnectorInfo { + connector_id: "alpha".to_string(), + connector_name: "Alpha".to_string(), + connector_description: Some("Alpha connector".to_string()), + logo_url: None, + install_url: None, + is_accessible: false, + }, + ConnectorInfo { + connector_id: "beta".to_string(), + connector_name: "beta".to_string(), + connector_description: None, + logo_url: None, + install_url: None, + is_accessible: false, + }, + ]; + + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = start_apps_server(connectors.clone(), tools).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let first_request = mcp + .send_apps_list_request(AppsListParams { + limit: Some(1), + cursor: None, + }) + .await?; + let first_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_request)), + ) + .await??; + let AppsListResponse { + data: first_page, + next_cursor: first_cursor, + } = to_response(first_response)?; + + let expected_first = vec![AppInfo { + id: "beta".to_string(), + name: "Beta App".to_string(), + description: None, + logo_url: None, + install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), + is_accessible: true, + }]; + + assert_eq!(first_page, expected_first); + let next_cursor = first_cursor.ok_or_else(|| anyhow::anyhow!("missing cursor"))?; + + let second_request = mcp + .send_apps_list_request(AppsListParams { + limit: Some(1), + cursor: Some(next_cursor), + }) + .await?; + let second_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_request)), + ) + .await??; + let AppsListResponse { + data: second_page, + next_cursor: second_cursor, + } = to_response(second_response)?; + + let expected_second = vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: false, + }]; + + assert_eq!(second_page, expected_second); + assert!(second_cursor.is_none()); + + server_handle.abort(); + Ok(()) +} + +#[derive(Clone)] +struct AppsServerState { + expected_bearer: String, + expected_account_id: String, + response: serde_json::Value, +} + +#[derive(Clone)] +struct AppListMcpServer { + tools: Arc>, +} + +impl AppListMcpServer { + fn new(tools: Arc>) -> Self { + Self { tools } + } +} + +impl ServerHandler for AppListMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..ServerInfo::default() + } + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ + { + let tools = self.tools.clone(); + async move { + Ok(ListToolsResult { + tools: (*tools).clone(), + next_cursor: None, + meta: None, + }) + } + } +} + +async fn start_apps_server( + connectors: Vec, + tools: Vec, +) -> Result<(String, JoinHandle<()>)> { + let state = AppsServerState { + expected_bearer: "Bearer chatgpt-token".to_string(), + expected_account_id: "account-123".to_string(), + response: json!({ "connectors": connectors }), + }; + let state = Arc::new(state); + let tools = Arc::new(tools); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let mcp_service = StreamableHttpService::new( + { + let tools = tools.clone(); + move || Ok(AppListMcpServer::new(tools.clone())) + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + + let router = Router::new() + .route("/aip/connectors/list_accessible", post(list_connectors)) + .with_state(state) + .nest_service("/api/codex/apps", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle)) +} + +async fn list_connectors( + State(state): State>, + headers: HeaderMap, +) -> Result { + let bearer_ok = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_bearer); + let account_ok = headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == state.expected_account_id); + + if bearer_ok && account_ok { + Ok(Json(state.response.clone())) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +fn connector_tool(connector_id: &str, connector_name: &str) -> Result { + let schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "additionalProperties": false + }))?; + let mut tool = Tool::new( + Cow::Owned(format!("connector_{connector_id}")), + Cow::Borrowed("Connector test tool"), + Arc::new(schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + let mut meta = Meta::new(); + meta.0 + .insert("connector_id".to_string(), json!(connector_id)); + meta.0 + .insert("connector_name".to_string(), json!(connector_name)); + tool.meta = Some(meta); + Ok(tool) +} + +fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +chatgpt_base_url = "{base_url}" + +[features] +connectors = true +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index bf6230ae52..9d1dc3f78b 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,5 +1,6 @@ mod account; mod analytics; +mod app_list; mod collaboration_mode_list; mod config_rpc; mod initialize;