| use std::process::Output; |
| use std::process::Stdio; |
| use std::time::Duration; |
|
|
| use anyhow::Context; |
| use anyhow::Result; |
| use anyhow::ensure; |
| use app_test_support::ChatGptAuthFixture; |
| use app_test_support::write_chatgpt_auth; |
| use codex_config::ConfigLoadOptions; |
| use codex_config::types::AuthCredentialsStoreMode; |
| use codex_core::config::load_config_toml_with_layer_stack; |
| use codex_core::config::load_global_mcp_servers; |
| use codex_core_plugins::installed_marketplaces::marketplace_install_root; |
| use codex_utils_absolute_path::AbsolutePathBuf; |
| use pretty_assertions::assert_eq; |
| use serde_json::Value; |
| use serde_json::json; |
| use tempfile::TempDir; |
| use tokio::io::AsyncBufReadExt; |
| use tokio::io::AsyncWriteExt; |
| use tokio::io::BufReader; |
| use tokio::net::TcpStream; |
| use tokio::process::Command; |
| use tokio::time::timeout; |
| use url::Url; |
| use wiremock::Mock; |
| use wiremock::MockServer; |
| use wiremock::ResponseTemplate; |
| use wiremock::matchers::body_string_contains; |
| use wiremock::matchers::header; |
| use wiremock::matchers::method; |
| use wiremock::matchers::path; |
|
|
| const MANAGED_SERVER_NAME: &str = "managed-slack"; |
| const MANAGED_CLIENT_ID: &str = "managed-oauth-client"; |
| const MANAGED_SCOPE: &str = "managed.read"; |
| const MOCK_ACCESS_TOKEN: &str = "mock-managed-access-token"; |
| const MOCK_REFRESH_TOKEN: &str = "mock-managed-refresh-token"; |
|
|
| struct CloudManagedConfigFixture { |
| server: MockServer, |
| codex_home: TempDir, |
| user_config: String, |
| mcp_url: String, |
| } |
|
|
| impl CloudManagedConfigFixture { |
| async fn new() -> Result<Option<Self>> { |
| let server = MockServer::start().await; |
| let chatgpt_base_url = format!("{}/backend-api", server.uri()); |
| let codex_home = TempDir::new()?; |
| let user_config = format!( |
| "cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"{chatgpt_base_url}\"\n" |
| ); |
| std::fs::write(codex_home.path().join("config.toml"), &user_config)?; |
|
|
| let bootstrap_config = load_config_toml_with_layer_stack( |
| codex_home.path(), |
| Some(&AbsolutePathBuf::from_absolute_path(codex_home.path())?), |
| Vec::new(), |
| ConfigLoadOptions::default(), |
| ) |
| .await?; |
| if bootstrap_config.config_toml.cli_auth_credentials_store |
| != Some(AuthCredentialsStoreMode::File) |
| || bootstrap_config.config_toml.chatgpt_base_url.as_deref() |
| != Some(chatgpt_base_url.as_str()) |
| { |
| eprintln!( |
| "skipping cloud-managed subprocess: host-managed authentication or backend routing prevents isolated mock credentials" |
| ); |
| return Ok(None); |
| } |
|
|
| write_chatgpt_auth( |
| codex_home.path(), |
| ChatGptAuthFixture::new("chatgpt-token") |
| .account_id("workspace-123") |
| .chatgpt_account_id("workspace-123") |
| .chatgpt_user_id("user-123") |
| .plan_type("enterprise"), |
| AuthCredentialsStoreMode::File, |
| )?; |
|
|
| let mcp_url = format!("{}/mcp", server.uri()); |
| let managed_config = format!( |
| "mcp_oauth_credentials_store = \"file\"\n\n\ |
| [mcp_servers.{MANAGED_SERVER_NAME}]\n\ |
| url = \"{mcp_url}\"\n\ |
| auth = \"oauth\"\n\ |
| scopes = [\"{MANAGED_SCOPE}\"]\n\n\ |
| [mcp_servers.{MANAGED_SERVER_NAME}.oauth]\n\ |
| client_id = \"{MANAGED_CLIENT_ID}\"\n\n\ |
| [marketplaces.managed]\n\ |
| source_type = \"git\"\n\ |
| source = \"https://github.com/owner/repo.git\"\n" |
| ); |
| Mock::given(method("GET")) |
| .and(path("/backend-api/wham/config/bundle")) |
| .and(header("authorization", "Bearer chatgpt-token")) |
| .and(header("chatgpt-account-id", "workspace-123")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(json!({ |
| "config_toml": { |
| "enterprise_managed": [{ |
| "id": "managed-config", |
| "name": "Managed resources", |
| "contents": managed_config, |
| }], |
| }, |
| }))) |
| .expect(1) |
| .mount(&server) |
| .await; |
|
|
| Ok(Some(Self { |
| server, |
| codex_home, |
| user_config, |
| mcp_url, |
| })) |
| } |
|
|
| fn command(&self, args: &[&str]) -> Result<Command> { |
| let mut command = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); |
| command |
| .kill_on_drop(true) |
| .current_dir(self.codex_home.path()) |
| .env("CODEX_HOME", self.codex_home.path()) |
| .env("NO_PROXY", "127.0.0.1,localhost") |
| .env("no_proxy", "127.0.0.1,localhost") |
| .env_remove("CODEX_ACCESS_TOKEN") |
| .env_remove("CODEX_API_KEY") |
| .env_remove("OPENAI_API_KEY") |
| .args(args); |
| Ok(command) |
| } |
|
|
| async fn output(&self, args: &[&str]) -> Result<Output> { |
| let output = self.command(args)?.output().await?; |
| ensure!( |
| output.status.success(), |
| "codex {} failed with status {}: stdout={}; stderr={}", |
| args.join(" "), |
| output.status, |
| String::from_utf8_lossy(&output.stdout), |
| String::from_utf8_lossy(&output.stderr), |
| ); |
| Ok(output) |
| } |
|
|
| fn assert_user_config_unchanged(&self) -> Result<()> { |
| assert_eq!( |
| std::fs::read_to_string(self.codex_home.path().join("config.toml"))?, |
| self.user_config |
| ); |
| Ok(()) |
| } |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn list_and_get_resolve_cloud_managed_mcp_without_writing_user_config() -> Result<()> { |
| let Some(fixture) = CloudManagedConfigFixture::new().await? else { |
| return Ok(()); |
| }; |
|
|
| let output = fixture.output(&["mcp", "list", "--json"]).await?; |
| let entries: Value = serde_json::from_slice(&output.stdout)?; |
| assert_eq!(entries[0]["name"], MANAGED_SERVER_NAME); |
| assert_eq!(entries[0]["transport"]["type"], "streamable_http"); |
| assert_eq!(entries[0]["transport"]["url"], fixture.mcp_url); |
|
|
| let output = fixture |
| .output(&["mcp", "get", MANAGED_SERVER_NAME, "--json"]) |
| .await?; |
| let entry: Value = serde_json::from_slice(&output.stdout)?; |
| assert_eq!(entry["name"], MANAGED_SERVER_NAME); |
| assert_eq!(entry["transport"]["url"], fixture.mcp_url); |
| assert!( |
| fixture |
| .codex_home |
| .path() |
| .join("cloud-config-bundle-cache.json") |
| .exists() |
| ); |
| fixture.assert_user_config_unchanged()?; |
| fixture.server.verify().await; |
|
|
| Ok(()) |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn login_and_logout_persist_only_cloud_managed_mcp_oauth_credentials() -> Result<()> { |
| let Some(fixture) = CloudManagedConfigFixture::new().await? else { |
| return Ok(()); |
| }; |
|
|
| let challenge = format!( |
| "Bearer resource_metadata=\"{}/oauth-resource\"", |
| fixture.server.uri() |
| ); |
| Mock::given(method("GET")) |
| .and(path("/mcp")) |
| .respond_with(ResponseTemplate::new(401).insert_header("WWW-Authenticate", challenge)) |
| .mount(&fixture.server) |
| .await; |
| Mock::given(method("GET")) |
| .and(path("/oauth-resource")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(json!({ |
| "resource": fixture.mcp_url, |
| "authorization_servers": [fixture.server.uri()], |
| }))) |
| .mount(&fixture.server) |
| .await; |
| let oauth_metadata = json!({ |
| "issuer": fixture.server.uri(), |
| "authorization_endpoint": format!("{}/oauth/authorize", fixture.server.uri()), |
| "token_endpoint": format!("{}/oauth/token", fixture.server.uri()), |
| "response_types_supported": ["code"], |
| "code_challenge_methods_supported": ["S256"], |
| "scopes_supported": [MANAGED_SCOPE], |
| }); |
| for metadata_path in [ |
| "/.well-known/oauth-authorization-server", |
| "/.well-known/oauth-authorization-server/mcp", |
| ] { |
| Mock::given(method("GET")) |
| .and(path(metadata_path)) |
| .respond_with(ResponseTemplate::new(200).set_body_json(oauth_metadata.clone())) |
| .mount(&fixture.server) |
| .await; |
| } |
| Mock::given(method("POST")) |
| .and(path("/oauth/token")) |
| .and(body_string_contains(format!( |
| "client_id={MANAGED_CLIENT_ID}" |
| ))) |
| .and(body_string_contains("grant_type=authorization_code")) |
| .respond_with(ResponseTemplate::new(200).set_body_json(json!({ |
| "access_token": MOCK_ACCESS_TOKEN, |
| "token_type": "Bearer", |
| "expires_in": 3600, |
| "refresh_token": MOCK_REFRESH_TOKEN, |
| "scope": MANAGED_SCOPE, |
| }))) |
| .expect(1) |
| .mount(&fixture.server) |
| .await; |
|
|
| let mut command = fixture.command(&["mcp", "login", MANAGED_SERVER_NAME])?; |
| command.stdout(Stdio::piped()).stderr(Stdio::inherit()); |
| let mut child = command.spawn()?; |
| let stdout = child |
| .stdout |
| .take() |
| .context("MCP login did not provide captured stdout")?; |
| let mut lines = BufReader::new(stdout).lines(); |
| let authorization_url = timeout(Duration::from_secs(30), async { |
| while let Some(line) = lines.next_line().await? { |
| if line.starts_with("http://") || line.starts_with("https://") { |
| return Ok::<_, anyhow::Error>(Url::parse(line.trim())?); |
| } |
| } |
| anyhow::bail!("MCP login exited before printing its OAuth authorization URL") |
| }) |
| .await |
| .context("timed out waiting for the managed MCP authorization URL")??; |
|
|
| let query_pairs: Vec<_> = authorization_url.query_pairs().into_owned().collect(); |
| let state = query_pairs |
| .iter() |
| .find(|(name, _)| name == "state") |
| .map(|(_, value)| value.as_str()) |
| .context("managed MCP authorization URL did not contain OAuth state")?; |
| assert_eq!( |
| query_pairs |
| .iter() |
| .find(|(name, _)| name == "client_id") |
| .map(|(_, value)| value.as_str()), |
| Some(MANAGED_CLIENT_ID) |
| ); |
| assert_eq!( |
| query_pairs |
| .iter() |
| .find(|(name, _)| name == "scope") |
| .map(|(_, value)| value.as_str()), |
| Some(MANAGED_SCOPE) |
| ); |
| let redirect_uri = query_pairs |
| .iter() |
| .find(|(name, _)| name == "redirect_uri") |
| .map(|(_, value)| value.as_str()) |
| .context("managed MCP authorization URL did not contain a callback")?; |
| let mut callback_url = Url::parse(redirect_uri)?; |
| callback_url |
| .query_pairs_mut() |
| .append_pair("code", "mock-managed-authorization-code") |
| .append_pair("state", state); |
| let callback_host = callback_url |
| .host_str() |
| .context("managed MCP callback did not contain a host")?; |
| let callback_port = callback_url |
| .port_or_known_default() |
| .context("managed MCP callback did not contain a port")?; |
| let callback_path = match callback_url.query() { |
| Some(query) => format!("{}?{query}", callback_url.path()), |
| None => callback_url.path().to_string(), |
| }; |
| let callback_response = timeout(Duration::from_secs(30), async { |
| let mut callback = TcpStream::connect((callback_host, callback_port)).await?; |
| callback |
| .write_all( |
| format!( |
| "GET {callback_path} HTTP/1.1\r\nHost: {callback_host}:{callback_port}\r\nConnection: close\r\n\r\n" |
| ) |
| .as_bytes(), |
| ) |
| .await?; |
| let mut response_lines = BufReader::new(callback).lines(); |
| response_lines |
| .next_line() |
| .await? |
| .context("managed MCP OAuth callback returned an empty HTTP response") |
| }) |
| .await |
| .context("timed out waiting for the managed MCP OAuth callback response")??; |
| ensure!( |
| callback_response.starts_with("HTTP/1.1 200") |
| || callback_response.starts_with("HTTP/1.0 200"), |
| "managed MCP OAuth callback failed: {callback_response}" |
| ); |
|
|
| let login_status = timeout(Duration::from_secs(30), child.wait()) |
| .await |
| .context("timed out waiting for managed MCP login")??; |
| ensure!( |
| login_status.success(), |
| "managed MCP login failed: status={login_status}" |
| ); |
| timeout(Duration::from_secs(30), async { |
| while let Some(line) = lines.next_line().await? { |
| if line.contains("Successfully logged in to MCP server 'managed-slack'.") { |
| return Ok::<_, anyhow::Error>(()); |
| } |
| } |
| anyhow::bail!("managed MCP login exited before printing its success message") |
| }) |
| .await |
| .context("timed out waiting for the managed MCP login success message")??; |
|
|
| let credentials_path = fixture.codex_home.path().join(".credentials.json"); |
| let credentials: Value = serde_json::from_slice(&std::fs::read(&credentials_path)?)?; |
| let entries = credentials |
| .as_object() |
| .context("MCP credentials should be a JSON object")?; |
| assert_eq!(entries.len(), 1); |
| let credential = entries |
| .values() |
| .next() |
| .context("managed MCP OAuth credentials were not persisted")?; |
| assert_eq!(credential["server_name"], MANAGED_SERVER_NAME); |
| assert_eq!(credential["server_url"], fixture.mcp_url); |
| assert_eq!(credential["client_id"], MANAGED_CLIENT_ID); |
| assert_eq!(credential["access_token"], MOCK_ACCESS_TOKEN); |
| assert_eq!(credential["refresh_token"], MOCK_REFRESH_TOKEN); |
| assert_eq!(credential["scopes"], json!([MANAGED_SCOPE])); |
| fixture.assert_user_config_unchanged()?; |
|
|
| let list_output = fixture.output(&["mcp", "list", "--json"]).await?; |
| let entries: Value = serde_json::from_slice(&list_output.stdout)?; |
| assert_eq!(entries[0]["name"], MANAGED_SERVER_NAME); |
| assert_eq!(entries[0]["auth_status"], "o_auth"); |
|
|
| let logout_output = fixture |
| .output(&["mcp", "logout", MANAGED_SERVER_NAME]) |
| .await?; |
| assert!( |
| String::from_utf8(logout_output.stdout)? |
| .contains("Removed OAuth credentials for 'managed-slack'.") |
| ); |
| assert!(!credentials_path.exists()); |
| fixture.assert_user_config_unchanged()?; |
| fixture.server.verify().await; |
|
|
| Ok(()) |
| } |
|
|
| #[tokio::test(flavor = "multi_thread", worker_threads = 2)] |
| async fn add_and_remove_preserve_cloud_managed_resources() -> Result<()> { |
| let Some(fixture) = CloudManagedConfigFixture::new().await? else { |
| return Ok(()); |
| }; |
|
|
| let installed_root = marketplace_install_root(fixture.codex_home.path()).join("managed"); |
| std::fs::create_dir_all(&installed_root)?; |
| let marker = installed_root.join("marker.txt"); |
| std::fs::write(&marker, "installed")?; |
| |
| for _ in 0..2 { |
| let output = fixture |
| .command(&["plugin", "marketplace", "remove", "managed"])? |
| .output() |
| .await?; |
| assert!(!output.status.success()); |
| assert!(String::from_utf8(output.stderr)?.contains( |
| "marketplace `managed` is configured in enterprise-managed (Managed resources, managed-config); remove it from that configuration source instead" |
| )); |
| fixture.assert_user_config_unchanged()?; |
| assert_eq!(std::fs::read_to_string(&marker)?, "installed"); |
| } |
|
|
| fixture |
| .output(&["mcp", "get", MANAGED_SERVER_NAME, "--json"]) |
| .await?; |
| fixture |
| .output(&["mcp", "add", "local-docs", "--", "echo", "hello"]) |
| .await?; |
| let local_servers = load_global_mcp_servers(fixture.codex_home.path()).await?; |
| assert!(local_servers.contains_key("local-docs")); |
| assert!(!local_servers.contains_key(MANAGED_SERVER_NAME)); |
|
|
| let output = fixture |
| .output(&["mcp", "remove", MANAGED_SERVER_NAME]) |
| .await?; |
| assert!( |
| String::from_utf8(output.stdout)?.contains("No MCP server named 'managed-slack' found.") |
| ); |
| let local_servers = load_global_mcp_servers(fixture.codex_home.path()).await?; |
| assert!(local_servers.contains_key("local-docs")); |
| assert!(!local_servers.contains_key(MANAGED_SERVER_NAME)); |
|
|
| fixture.output(&["mcp", "remove", "local-docs"]).await?; |
| assert!( |
| load_global_mcp_servers(fixture.codex_home.path()) |
| .await? |
| .is_empty() |
| ); |
| fixture |
| .output(&["mcp", "get", MANAGED_SERVER_NAME, "--json"]) |
| .await?; |
| fixture.server.verify().await; |
|
|
| Ok(()) |
| } |
|
|