From 45189ca9335b7f1d5dc801c94e82273c2ac2f359 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Wed, 11 Mar 2026 17:54:02 +0000 Subject: [PATCH 1/4] Connectors: Add API key source detection and refactor REST dispatch Add `_wp_connectors_get_api_key_source()` to detect whether an API key comes from an environment variable, PHP constant, or the database. This enables the UI to show the key source and hide the remove button for externally configured keys. Refactor API key validation and masking from `sanitize_callback` and `option_` filters into a single `rest_post_dispatch` handler (`_wp_connectors_rest_settings_dispatch`). This ensures raw keys are never exposed via the REST API and simplifies the validation flow. Enrich `_wp_connectors_get_connector_settings()` with plugin installation/activation status and static memoization. Update `_wp_connectors_get_connector_script_module_data()` to expose `keySource`, `isConnected`, `logoUrl`, and plugin status to the admin. Backports https://github.com/WordPress/gutenberg/pull/76266 Backports https://github.com/WordPress/gutenberg/pull/76327 updates include ref update --- package.json | 2 +- src/wp-includes/connectors.php | 192 +++++++++++++++++++++++---------- 2 files changed, 139 insertions(+), 55 deletions(-) diff --git a/package.json b/package.json index 117cf46f5455f..a8ba7e94cdbc3 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://develop.svn.wordpress.org/trunk" }, "gutenberg": { - "sha": "9b8144036fa5faf75de43d4502ff9809fcf689ad", + "sha": "74a4f254a45f7a303bd27b8f8e104786380e8103", "ghcrRepo": "WordPress/gutenberg/gutenberg-wp-develop-build" }, "engines": { diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index b8b9004354652..fd70a9890a735 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -338,6 +338,50 @@ function _wp_connectors_mask_api_key( string $key ): string { return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 ); } +/** + * Determines the source of an API key for a given provider. + * + * Checks in order: environment variable, PHP constant, database. + * Uses the same naming convention as the WP AI Client ProviderRegistry. + * + * @since 7.0.0 + * @access private + * + * @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google'). + * @return string The key source: 'env', 'constant', 'database', or 'none'. + */ +function _wp_connectors_get_api_key_source( string $provider_id ): string { + // Convert provider ID to CONSTANT_CASE for env var name. + // e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'. + $constant_case_id = strtoupper( + preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) ) + ); + $env_var_name = "{$constant_case_id}_API_KEY"; + + // Check environment variable first. + $env_value = getenv( $env_var_name ); + if ( false !== $env_value && '' !== $env_value ) { + return 'env'; + } + + // Check PHP constant. + if ( defined( $env_var_name ) ) { + $const_value = constant( $env_var_name ); + if ( is_string( $const_value ) && '' !== $const_value ) { + return 'constant'; + } + } + + // Check database. + $setting_name = "connectors_ai_{$provider_id}_api_key"; + $db_value = get_option( $setting_name, '' ); + if ( '' !== $db_value ) { + return 'database'; + } + + return 'none'; +} + /** * Checks whether an API key is valid for a given provider. * @@ -378,31 +422,48 @@ function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): } /** - * Retrieves the real (unmasked) value of a connector API key. - * - * Temporarily removes the masking filter, reads the option, then re-adds it. + * Gets the registered connector settings. * * @since 7.0.0 * @access private * - * @param string $option_name The option name for the API key. - * @param callable $mask_callback The mask filter function. - * @return string The real API key value. + * @return array { + * Connector settings keyed by connector ID. + * + * @type array ...$0 { + * Data for a single connector. + * + * @type string $name The connector's display name. + * @type string $description The connector's description. + * @type string $type The connector type. Currently, only 'ai_provider' is supported. + * @type array $plugin Optional. Plugin data for install/activate UI. + * @type string $slug The WordPress.org plugin slug. + * } + * @type array $authentication { + * Authentication configuration. When method is 'api_key', includes + * credentials_url and setting_name. When 'none', only method is present. + * + * @type string $method The authentication method: 'api_key' or 'none'. + * @type string|null $credentials_url Optional. URL where users can obtain API credentials. + * @type string $setting_name Optional. The setting name for the API key. + * } + * } + * } */ -function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string { - remove_filter( "option_{$option_name}", $mask_callback ); - $value = get_option( $option_name, '' ); - add_filter( "option_{$option_name}", $mask_callback ); - return (string) $value; +function _wp_connectors_get_connector_settings(): array { + $connectors = wp_get_connectors(); + ksort( $connectors ); + return $connectors; } /** - * Validates connector API keys in the REST response when explicitly requested. + * Masks and validates connector API keys in REST responses. * - * Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector - * fields via `_fields`. For each requested connector field, it validates the unmasked - * key against the provider and replaces the response value with `invalid_key` if - * validation fails. + * On every `/wp/v2/settings` response, masks connector API key values so raw + * keys are never exposed via the REST API. + * + * On POST or PUT requests, validates each updated key against the provider + * before masking. If validation fails, the key is reverted to an empty string. * * @since 7.0.0 * @access private @@ -410,57 +471,55 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca * @param WP_REST_Response $response The response object. * @param WP_REST_Server $server The server instance. * @param WP_REST_Request $request The request object. - * @return WP_REST_Response The potentially modified response. + * @return WP_REST_Response The modified response with masked/validated keys. */ -function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response { +function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response { if ( '/wp/v2/settings' !== $request->get_route() ) { return $response; } - $fields = $request->get_param( '_fields' ); - if ( ! $fields ) { - return $response; - } - - if ( is_array( $fields ) ) { - $requested = $fields; - } else { - $requested = array_map( 'trim', explode( ',', $fields ) ); - } - $data = $response->get_data(); if ( ! is_array( $data ) ) { return $response; } - foreach ( wp_get_connectors() as $connector_id => $connector_data ) { + $is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method(); + + foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; - if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } $setting_name = $auth['setting_name']; - if ( ! in_array( $setting_name, $requested, true ) ) { + if ( ! array_key_exists( $setting_name, $data ) ) { continue; } - $real_key = _wp_connectors_get_real_api_key( $setting_name, '_wp_connectors_mask_api_key' ); - if ( '' === $real_key ) { - continue; + $value = $data[ $setting_name ]; + + // On update, validate the key before masking. + if ( $is_update && is_string( $value ) && '' !== $value ) { + if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) { + update_option( $setting_name, '' ); + $data[ $setting_name ] = ''; + continue; + } } - if ( true !== _wp_connectors_is_ai_api_key_valid( $real_key, $connector_id ) ) { - $data[ $setting_name ] = 'invalid_key'; + // Mask the key in the response. + if ( is_string( $value ) && '' !== $value ) { + $data[ $setting_name ] = _wp_connectors_mask_api_key( $value ); } } $response->set_data( $data ); return $response; } -add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 ); +add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3 ); /** - * Registers default connector settings and mask/sanitize filters. + * Registers default connector settings. * * @since 7.0.0 * @access private @@ -479,10 +538,9 @@ function _wp_register_default_connector_settings(): void { continue; } - $setting_name = $auth['setting_name']; register_setting( 'connectors', - $setting_name, + $auth['setting_name'], array( 'type' => 'string', 'label' => sprintf( @@ -497,18 +555,9 @@ function _wp_register_default_connector_settings(): void { ), 'default' => '', 'show_in_rest' => true, - 'sanitize_callback' => static function ( string $value ) use ( $connector_id ): string { - $value = sanitize_text_field( $value ); - if ( '' === $value ) { - return $value; - } - - $valid = _wp_connectors_is_ai_api_key_valid( $value, $connector_id ); - return true === $valid ? $value : ''; - }, + 'sanitize_callback' => 'sanitize_text_field', ) ); - add_filter( "option_{$setting_name}", '_wp_connectors_mask_api_key' ); } } add_action( 'init', '_wp_register_default_connector_settings', 20 ); @@ -536,7 +585,13 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { continue; } - $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' ); + // Skip if the key is already provided via env var or constant. + $key_source = _wp_connectors_get_api_key_source( $connector_id ); + if ( 'env' === $key_source || 'constant' === $key_source ) { + continue; + } + + $api_key = get_option( $auth['setting_name'], '' ); if ( '' === $api_key ) { continue; } @@ -562,6 +617,18 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { * @return array Script module data with connectors added. */ function _wp_connectors_get_connector_script_module_data( array $data ): array { + $registry = AiClient::defaultRegistry(); + + // Build a slug-to-file map for plugin installation status. + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + $plugin_files_by_slug = array(); + foreach ( array_keys( get_plugins() ) as $plugin_file ) { + $slug = str_contains( $plugin_file, '/' ) ? dirname( $plugin_file ) : str_replace( '.php', '', $plugin_file ); + $plugin_files_by_slug[ $slug ] = $plugin_file; + } + $connectors = array(); foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; @@ -570,17 +637,34 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { if ( 'api_key' === $auth['method'] ) { $auth_out['settingName'] = $auth['setting_name'] ?? ''; $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null; + $auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id ); + try { + $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); + } catch ( Exception $e ) { + $auth_out['isConnected'] = false; + } } $connector_out = array( 'name' => $connector_data['name'], 'description' => $connector_data['description'], + 'logoUrl' => ! empty( $connector_data['logo_url'] ) ? $connector_data['logo_url'] : null, 'type' => $connector_data['type'], 'authentication' => $auth_out, ); - if ( ! empty( $connector_data['plugin'] ) ) { - $connector_out['plugin'] = $connector_data['plugin']; + if ( ! empty( $connector_data['plugin']['slug'] ) ) { + $plugin_slug = $connector_data['plugin']['slug']; + $plugin_file = $plugin_files_by_slug[ $plugin_slug ] ?? null; + + $is_installed = null !== $plugin_file; + $is_activated = $is_installed && is_plugin_active( $plugin_file ); + + $connector_out['plugin'] = array( + 'slug' => $plugin_slug, + 'isInstalled' => $is_installed, + 'isActivated' => $is_activated, + ); } $connectors[ $connector_id ] = $connector_out; From 843c9440b67e90fd413cf33ad8d4f7399efcd7fa Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Mar 2026 10:19:11 +0000 Subject: [PATCH 2/4] Connectors: Add API key source detection and refactor REST dispatch Add `_wp_connectors_get_api_key_source()` to detect whether an API key comes from an environment variable, PHP constant, or the database. This enables the UI to show the key source and hide the remove button for externally configured keys. Refactor API key validation and masking from `sanitize_callback` and `option_` filters into a single `rest_post_dispatch` handler (`_wp_connectors_rest_settings_dispatch`). This ensures raw keys are never exposed via the REST API and simplifies the validation flow. Enrich `_wp_connectors_get_connector_settings()` with plugin installation/activation status and static memoization. Update `_wp_connectors_get_connector_script_module_data()` to expose `keySource`, `isConnected`, `logoUrl`, and plugin status to the admin. Backports https://github.com/WordPress/gutenberg/pull/76266 Backports https://github.com/WordPress/gutenberg/pull/76327 updates include ref update # Conflicts: # src/wp-includes/connectors.php --- src/wp-includes/connectors.php | 37 +--------------------------------- 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index fd70a9890a735..95d76bdf855e0 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -421,41 +421,6 @@ function _wp_connectors_is_ai_api_key_valid( string $key, string $provider_id ): } } -/** - * Gets the registered connector settings. - * - * @since 7.0.0 - * @access private - * - * @return array { - * Connector settings keyed by connector ID. - * - * @type array ...$0 { - * Data for a single connector. - * - * @type string $name The connector's display name. - * @type string $description The connector's description. - * @type string $type The connector type. Currently, only 'ai_provider' is supported. - * @type array $plugin Optional. Plugin data for install/activate UI. - * @type string $slug The WordPress.org plugin slug. - * } - * @type array $authentication { - * Authentication configuration. When method is 'api_key', includes - * credentials_url and setting_name. When 'none', only method is present. - * - * @type string $method The authentication method: 'api_key' or 'none'. - * @type string|null $credentials_url Optional. URL where users can obtain API credentials. - * @type string $setting_name Optional. The setting name for the API key. - * } - * } - * } - */ -function _wp_connectors_get_connector_settings(): array { - $connectors = wp_get_connectors(); - ksort( $connectors ); - return $connectors; -} - /** * Masks and validates connector API keys in REST responses. * @@ -485,7 +450,7 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R $is_update = 'POST' === $request->get_method() || 'PUT' === $request->get_method(); - foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { + foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; From 54bd359582a2f4d186f4c9226b4f5c14e1355a8c Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Mar 2026 11:28:28 +0000 Subject: [PATCH 3/4] review feedback --- src/wp-includes/connectors.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 95d76bdf855e0..575f71da7766c 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -347,10 +347,11 @@ function _wp_connectors_mask_api_key( string $key ): string { * @since 7.0.0 * @access private * - * @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google'). + * @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google'). + * @param string $setting_name The option name for the API key (e.g., 'connectors_ai_openai_api_key'). * @return string The key source: 'env', 'constant', 'database', or 'none'. */ -function _wp_connectors_get_api_key_source( string $provider_id ): string { +function _wp_connectors_get_api_key_source( string $provider_id, string $setting_name ): string { // Convert provider ID to CONSTANT_CASE for env var name. // e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'. $constant_case_id = strtoupper( @@ -373,8 +374,7 @@ function _wp_connectors_get_api_key_source( string $provider_id ): string { } // Check database. - $setting_name = "connectors_ai_{$provider_id}_api_key"; - $db_value = get_option( $setting_name, '' ); + $db_value = get_option( $setting_name, '' ); if ( '' !== $db_value ) { return 'database'; } @@ -452,7 +452,7 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; - if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } @@ -551,7 +551,7 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { } // Skip if the key is already provided via env var or constant. - $key_source = _wp_connectors_get_api_key_source( $connector_id ); + $key_source = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ); if ( 'env' === $key_source || 'constant' === $key_source ) { continue; } @@ -602,7 +602,7 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { if ( 'api_key' === $auth['method'] ) { $auth_out['settingName'] = $auth['setting_name'] ?? ''; $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null; - $auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id ); + $auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ?? '' ); try { $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); } catch ( Exception $e ) { From 82c981100304604bd48be97149cae84554597357 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Thu, 12 Mar 2026 11:56:19 +0000 Subject: [PATCH 4/4] filter prioerity change --- src/wp-includes/default-filters.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 2b5d18923a8eb..dd3786aef5059 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -540,7 +540,7 @@ add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); // Connectors API. -add_action( 'init', '_wp_connectors_init' ); +add_action( 'init', '_wp_connectors_init', 15 ); // Sitemaps actions. add_action( 'init', 'wp_sitemaps_get_server' );