diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php new file mode 100644 index 0000000000000..85a92e31f98df --- /dev/null +++ b/src/wp-includes/connectors.php @@ -0,0 +1,280 @@ +hasProvider( $provider_id ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: %s: AI provider ID. */ + __( 'The provider "%s" is not registered in the AI client registry.' ), + $provider_id + ), + '7.0.0' + ); + return null; + } + + $registry->setProviderRequestAuthentication( + $provider_id, + new ApiKeyRequestAuthentication( $key ) + ); + + return $registry->isProviderConfigured( $provider_id ); + } catch ( Exception $e ) { + wp_trigger_error( __FUNCTION__, $e->getMessage() ); + return null; + } +} + +/** + * Retrieves the real (unmasked) value of a connector API key. + * + * Temporarily removes the masking filter, reads the option, then re-adds it. + * + * @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. + */ +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; +} + +/** + * Gets the registered connector provider settings. + * + * @since 7.0.0 + * @access private + * + * @return array Provider settings keyed by setting name. + */ +function _wp_connectors_get_provider_settings(): array { + $providers = array( + 'google' => array( + 'name' => 'Google', + ), + 'openai' => array( + 'name' => 'OpenAI', + ), + 'anthropic' => array( + 'name' => 'Anthropic', + ), + ); + + $provider_settings = array(); + foreach ( $providers as $provider => $data ) { + $setting_name = "connectors_ai_{$provider}_api_key"; + + $provider_settings[ $setting_name ] = array( + 'provider' => $provider, + 'label' => sprintf( + /* translators: %s: AI provider name. */ + __( '%s API Key' ), + $data['name'] + ), + 'description' => sprintf( + /* translators: %s: AI provider name. */ + __( 'API key for the %s AI provider.' ), + $data['name'] + ), + 'mask' => '_wp_connectors_mask_api_key', + 'sanitize' => static function ( string $value ) use ( $provider ): string { + $value = sanitize_text_field( $value ); + if ( '' === $value ) { + return $value; + } + + $valid = _wp_connectors_is_api_key_valid( $value, $provider ); + return true === $valid ? $value : ''; + }, + ); + } + return $provider_settings; +} + +/** + * Validates connector API keys in the REST response when explicitly requested. + * + * 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. + * + * @since 7.0.0 + * @access private + * + * @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. + */ +function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response { + if ( '/wp/v2/settings' !== $request->get_route() ) { + return $response; + } + + if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { + 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_connectors_get_provider_settings() as $setting_name => $config ) { + if ( ! in_array( $setting_name, $requested, true ) ) { + continue; + } + + $real_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] ); + if ( '' === $real_key ) { + continue; + } + + if ( true !== _wp_connectors_is_api_key_valid( $real_key, $config['provider'] ) ) { + $data[ $setting_name ] = 'invalid_key'; + } + } + + $response->set_data( $data ); + return $response; +} +add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 ); + +/** + * Registers default connector settings and mask/sanitize filters. + * + * @since 7.0.0 + * @access private + */ +function _wp_register_default_connector_settings(): void { + if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { + return; + } + + foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) { + register_setting( + 'connectors', + $setting_name, + array( + 'type' => 'string', + 'label' => $config['label'], + 'description' => $config['description'], + 'default' => '', + 'show_in_rest' => true, + 'sanitize_callback' => $config['sanitize'], + ) + ); + add_filter( "option_{$setting_name}", $config['mask'] ); + } +} +add_action( 'init', '_wp_register_default_connector_settings' ); + +/** + * Passes stored connector API keys to the WP AI client. + * + * @since 7.0.0 + * @access private + */ +function _wp_connectors_pass_default_keys_to_ai_client(): void { + if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { + return; + } + try { + $registry = AiClient::defaultRegistry(); + foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) { + $api_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] ); + if ( '' === $api_key || ! $registry->hasProvider( $config['provider'] ) ) { + continue; + } + + $registry->setProviderRequestAuthentication( + $config['provider'], + new ApiKeyRequestAuthentication( $api_key ) + ); + } + } catch ( Exception $e ) { + wp_trigger_error( __FUNCTION__, $e->getMessage() ); + } +} +add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client' ); diff --git a/src/wp-settings.php b/src/wp-settings.php index 90741401e800c..023cdccd5ecc9 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -294,6 +294,7 @@ require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; require ABSPATH . WPINC . '/ai-client.php'; +require ABSPATH . WPINC . '/connectors.php'; require ABSPATH . WPINC . '/class-wp-icons-registry.php'; require ABSPATH . WPINC . '/widgets.php'; require ABSPATH . WPINC . '/class-wp-widget.php'; diff --git a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php new file mode 100644 index 0000000000000..326ead2ed771e --- /dev/null +++ b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php @@ -0,0 +1,172 @@ +hasProvider( 'mock_connectors_test' ) ) { + $registry->registerProvider( Mock_Connectors_Test_Provider::class ); + } + } + + /** + * Sets whether the mock provider reports as configured. + * + * @param bool $is_configured Whether the provider should be configured. + */ + private static function set_mock_provider_configured( bool $is_configured ): void { + Mock_Connectors_Test_Provider_Availability::$is_configured = $is_configured; + } +} diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php new file mode 100644 index 0000000000000..8d49391d34d38 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpConnectorsGetProviderSettings.php @@ -0,0 +1,46 @@ +assertArrayHasKey( 'connectors_ai_google_api_key', $settings ); + $this->assertArrayHasKey( 'connectors_ai_openai_api_key', $settings ); + $this->assertArrayHasKey( 'connectors_ai_anthropic_api_key', $settings ); + $this->assertCount( 3, $settings ); + } + + /** + * @ticket 64730 + */ + public function test_each_setting_has_required_fields() { + $settings = _wp_connectors_get_provider_settings(); + $required_keys = array( 'provider', 'label', 'description', 'mask', 'sanitize' ); + + foreach ( $settings as $setting_name => $config ) { + foreach ( $required_keys as $key ) { + $this->assertArrayHasKey( $key, $config, "Setting '{$setting_name}' is missing '{$key}'." ); + } + } + } + + /** + * @ticket 64730 + */ + public function test_provider_values_match_expected() { + $settings = _wp_connectors_get_provider_settings(); + + $this->assertSame( 'google', $settings['connectors_ai_google_api_key']['provider'] ); + $this->assertSame( 'openai', $settings['connectors_ai_openai_api_key']['provider'] ); + $this->assertSame( 'anthropic', $settings['connectors_ai_anthropic_api_key']['provider'] ); + } +} diff --git a/tests/phpunit/tests/connectors/wpConnectorsIsApiKeyValid.php b/tests/phpunit/tests/connectors/wpConnectorsIsApiKeyValid.php new file mode 100644 index 0000000000000..79d4129be5103 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpConnectorsIsApiKeyValid.php @@ -0,0 +1,69 @@ +setExpectedIncorrectUsage( '_wp_connectors_is_api_key_valid' ); + + $result = _wp_connectors_is_api_key_valid( 'test-key', 'nonexistent_provider' ); + + $this->assertNull( $result ); + } + + /** + * Tests that a registered and configured provider returns true. + * + * @ticket 64730 + */ + public function test_configured_provider_returns_true() { + self::set_mock_provider_configured( true ); + + $result = _wp_connectors_is_api_key_valid( 'test-key', 'mock_connectors_test' ); + + $this->assertTrue( $result ); + } + + /** + * Tests that a registered but unconfigured provider returns false. + * + * @ticket 64730 + */ + public function test_unconfigured_provider_returns_false() { + self::set_mock_provider_configured( false ); + + $result = _wp_connectors_is_api_key_valid( 'test-key', 'mock_connectors_test' ); + + $this->assertFalse( $result ); + } +} diff --git a/tests/phpunit/tests/connectors/wpConnectorsMaskApiKey.php b/tests/phpunit/tests/connectors/wpConnectorsMaskApiKey.php new file mode 100644 index 0000000000000..77f63f4b53ede --- /dev/null +++ b/tests/phpunit/tests/connectors/wpConnectorsMaskApiKey.php @@ -0,0 +1,44 @@ +assertSame( $expected, _wp_connectors_mask_api_key( $input ) ); + } + + /** + * Data provider. + * + * @return array[] Test parameters { + * @type string $input API key to mask. + * @type string $expected Expected masked result. + * } + */ + public function data_mask_api_key(): array { + $bullet = "\u{2022}"; + + return array( + 'empty string' => array( '', '' ), + '1 char' => array( 'a', 'a' ), + '4 chars (boundary)' => array( 'abcd', 'abcd' ), + '5 chars (1 bullet + last 4)' => array( 'abcde', $bullet . 'bcde' ), + '20 chars (cap at 16 bullets)' => array( '12345678901234567890', str_repeat( $bullet, 16 ) . '7890' ), + '30 chars (cap at 16 bullets)' => array( str_repeat( 'x', 30 ), str_repeat( $bullet, 16 ) . 'xxxx' ), + ); + } +} diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index dd79885d2b16d..ef9e72e6a6724 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -120,6 +120,10 @@ public function test_get_items() { 'default_comment_status', 'site_icon', // Registered in wp-includes/blocks/site-logo.php 'wp_enable_real_time_collaboration', + // Connectors API keys are registered in _wp_register_default_connector_settings() in wp-includes/connectors.php. + 'connectors_ai_anthropic_api_key', + 'connectors_ai_google_api_key', + 'connectors_ai_openai_api_key', ); if ( ! is_multisite() ) { diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index e1a8ffc96995f..ef59ce5ca5074 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11064,6 +11064,24 @@ mockedApiResponse.Schema = { "PATCH" ], "args": { + "connectors_ai_google_api_key": { + "title": "Google API Key", + "description": "API key for the Google AI provider.", + "type": "string", + "required": false + }, + "connectors_ai_openai_api_key": { + "title": "OpenAI API Key", + "description": "API key for the OpenAI AI provider.", + "type": "string", + "required": false + }, + "connectors_ai_anthropic_api_key": { + "title": "Anthropic API Key", + "description": "API key for the Anthropic AI provider.", + "type": "string", + "required": false + }, "title": { "title": "Title", "description": "Site title.", @@ -14634,6 +14652,9 @@ mockedApiResponse.CommentModel = { }; mockedApiResponse.settings = { + "connectors_ai_google_api_key": "", + "connectors_ai_openai_api_key": "", + "connectors_ai_anthropic_api_key": "", "title": "Test Blog", "description": "", "url": "http://example.org",