From 132f672cd68f9fede2fb0d65eb812070f08b702d Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 5 Mar 2026 12:01:10 +0100 Subject: [PATCH 01/22] Connectors: Add `wp_connectors_settings` filter for extensibility. Introduce a `wp_connectors_settings` filter in `_wp_connectors_get_connector_settings()` so plugins can add, modify, or remove connectors on the Connectors screen. The filter runs after built-in and AI Client registry providers are collected but before `setting_name` is auto-generated for API-key connectors. Also tighten the `setting_name` generation to only apply when `type === 'ai_provider'`, preventing non-AI connectors from receiving an incorrect `connectors_ai_*` setting name. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 21 ++- .../wpConnectorsGetConnectorSettings.php | 156 ++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 0da60353705c2..cd284128d8265 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -202,9 +202,26 @@ function _wp_connectors_get_connector_settings(): array { ksort( $connectors ); - // Add setting_name for connectors that use API key authentication. + /** + * Filters the registered connector settings. + * + * Allows plugins to add, modify, or remove connectors displayed + * on the Connectors screen. Runs after built-in and AI Client + * registry providers are collected, but before `setting_name` is + * generated for API-key connectors. + * + * @since 7.0.0 + * + * @param array $connectors Connector settings keyed by connector ID. + * Each entry is an array with keys: name, description, + * type, authentication (with method, and optionally + * credentials_url), and optionally plugin. + */ + $connectors = apply_filters( 'wp_connectors_settings', $connectors ); + + // Add setting_name for AI provider connectors that use API key authentication. foreach ( $connectors as $connector_id => $connector ) { - if ( 'api_key' === $connector['authentication']['method'] ) { + if ( 'ai_provider' === $connector['type'] && 'api_key' === $connector['authentication']['method'] ) { $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; } } diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index f2d0aa68ee0e1..bfa0cfe233449 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -115,4 +115,160 @@ public function test_includes_registered_provider_from_registry() { $this->assertNull( $mock['authentication']['credentials_url'] ); $this->assertSame( 'connectors_ai_mock_connectors_test_api_key', $mock['authentication']['setting_name'] ); } + + /** + * @ticket 64730 + */ + public function test_filter_can_add_new_connector() { + add_filter( + 'wp_connectors_settings', + static function ( $connectors ) { + $connectors['my_email_service'] = array( + 'name' => 'My Email Service', + 'description' => 'Send transactional emails.', + 'type' => 'email_service', + 'authentication' => array( 'method' => 'none' ), + ); + return $connectors; + } + ); + + $connectors = _wp_connectors_get_connector_settings(); + + $this->assertArrayHasKey( 'my_email_service', $connectors ); + $this->assertSame( 'My Email Service', $connectors['my_email_service']['name'] ); + $this->assertSame( 'email_service', $connectors['my_email_service']['type'] ); + $this->assertSame( 'none', $connectors['my_email_service']['authentication']['method'] ); + + remove_all_filters( 'wp_connectors_settings' ); + } + + /** + * @ticket 64730 + */ + public function test_filter_can_modify_existing_connector() { + add_filter( + 'wp_connectors_settings', + static function ( $connectors ) { + $connectors['google']['description'] = 'Custom description for Google.'; + return $connectors; + } + ); + + $connectors = _wp_connectors_get_connector_settings(); + + $this->assertSame( 'Custom description for Google.', $connectors['google']['description'] ); + + remove_all_filters( 'wp_connectors_settings' ); + } + + /** + * @ticket 64730 + */ + public function test_filter_can_remove_connector() { + add_filter( + 'wp_connectors_settings', + static function ( $connectors ) { + unset( $connectors['openai'] ); + return $connectors; + } + ); + + $connectors = _wp_connectors_get_connector_settings(); + + $this->assertArrayNotHasKey( 'openai', $connectors ); + // Other connectors remain. + $this->assertArrayHasKey( 'google', $connectors ); + $this->assertArrayHasKey( 'anthropic', $connectors ); + + remove_all_filters( 'wp_connectors_settings' ); + } + + /** + * @ticket 64730 + */ + public function test_filter_added_api_key_connector_gets_setting_name() { + add_filter( + 'wp_connectors_settings', + static function ( $connectors ) { + $connectors['custom_ai'] = array( + 'name' => 'Custom AI', + 'description' => 'A custom AI provider.', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/keys', + ), + ); + return $connectors; + } + ); + + $connectors = _wp_connectors_get_connector_settings(); + + $this->assertArrayHasKey( 'custom_ai', $connectors ); + $this->assertSame( + 'connectors_ai_custom_ai_api_key', + $connectors['custom_ai']['authentication']['setting_name'], + 'Connectors added via the filter with api_key auth should receive a setting_name automatically.' + ); + + remove_all_filters( 'wp_connectors_settings' ); + } + + /** + * @ticket 64730 + */ + public function test_filter_added_non_ai_api_key_connector_does_not_get_setting_name() { + add_filter( + 'wp_connectors_settings', + static function ( $connectors ) { + $connectors['my_crm'] = array( + 'name' => 'My CRM', + 'description' => 'CRM integration.', + 'type' => 'crm', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/crm-keys', + ), + ); + return $connectors; + } + ); + + $connectors = _wp_connectors_get_connector_settings(); + + $this->assertArrayHasKey( 'my_crm', $connectors ); + $this->assertArrayNotHasKey( + 'setting_name', + $connectors['my_crm']['authentication'], + 'Non-AI connectors should not receive an auto-generated setting_name.' + ); + + remove_all_filters( 'wp_connectors_settings' ); + } + + /** + * @ticket 64730 + */ + public function test_filter_receives_all_default_connectors() { + $received = null; + + add_filter( + 'wp_connectors_settings', + static function ( $connectors ) use ( &$received ) { + $received = $connectors; + return $connectors; + } + ); + + _wp_connectors_get_connector_settings(); + + $this->assertArrayHasKey( 'google', $received ); + $this->assertArrayHasKey( 'openai', $received ); + $this->assertArrayHasKey( 'anthropic', $received ); + $this->assertArrayHasKey( 'mock_connectors_test', $received ); + + remove_all_filters( 'wp_connectors_settings' ); + } } From ea5fe481bbc3404df4720969f7efe96a39065de3 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 5 Mar 2026 13:27:41 +0100 Subject: [PATCH 02/22] Connectors: Improve `wp_connectors_settings` filter PHPDoc and test cleanup. Use structured PHPDoc for the `$connectors` param in `apply_filters()` to mirror the function's return documentation. In tests, use closure references for filter removal before assertions to prevent leaking to other tests. Follow-up to [555bb8a833]. See #64791. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 25 ++- .../wpConnectorsGetConnectorSettings.php | 144 ++++++++---------- 2 files changed, 84 insertions(+), 85 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index cd284128d8265..e4f2327b4794e 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -212,10 +212,27 @@ function _wp_connectors_get_connector_settings(): array { * * @since 7.0.0 * - * @param array $connectors Connector settings keyed by connector ID. - * Each entry is an array with keys: name, description, - * type, authentication (with method, and optionally - * credentials_url), and optionally plugin. + * @param array $connectors { + * 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. 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. + * } + * } + * } */ $connectors = apply_filters( 'wp_connectors_settings', $connectors ); diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index bfa0cfe233449..e2bc42c2ebdeb 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -117,94 +117,84 @@ public function test_includes_registered_provider_from_registry() { } /** - * @ticket 64730 + * @ticket 64791 */ public function test_filter_can_add_new_connector() { - add_filter( - 'wp_connectors_settings', - static function ( $connectors ) { - $connectors['my_email_service'] = array( - 'name' => 'My Email Service', - 'description' => 'Send transactional emails.', - 'type' => 'email_service', - 'authentication' => array( 'method' => 'none' ), - ); - return $connectors; - } - ); + $callback = static function ( $connectors ) { + $connectors['my_email_service'] = array( + 'name' => 'My Email Service', + 'description' => 'Send transactional emails.', + 'type' => 'email_service', + 'authentication' => array( 'method' => 'none' ), + ); + return $connectors; + }; + add_filter( 'wp_connectors_settings', $callback ); $connectors = _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); $this->assertArrayHasKey( 'my_email_service', $connectors ); $this->assertSame( 'My Email Service', $connectors['my_email_service']['name'] ); $this->assertSame( 'email_service', $connectors['my_email_service']['type'] ); $this->assertSame( 'none', $connectors['my_email_service']['authentication']['method'] ); - - remove_all_filters( 'wp_connectors_settings' ); } /** - * @ticket 64730 + * @ticket 64791 */ public function test_filter_can_modify_existing_connector() { - add_filter( - 'wp_connectors_settings', - static function ( $connectors ) { - $connectors['google']['description'] = 'Custom description for Google.'; - return $connectors; - } - ); + $callback = static function ( $connectors ) { + $connectors['google']['description'] = 'Custom description for Google.'; + return $connectors; + }; + add_filter( 'wp_connectors_settings', $callback ); $connectors = _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); $this->assertSame( 'Custom description for Google.', $connectors['google']['description'] ); - - remove_all_filters( 'wp_connectors_settings' ); } /** - * @ticket 64730 + * @ticket 64791 */ public function test_filter_can_remove_connector() { - add_filter( - 'wp_connectors_settings', - static function ( $connectors ) { - unset( $connectors['openai'] ); - return $connectors; - } - ); + $callback = static function ( $connectors ) { + unset( $connectors['openai'] ); + return $connectors; + }; + add_filter( 'wp_connectors_settings', $callback ); $connectors = _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); $this->assertArrayNotHasKey( 'openai', $connectors ); // Other connectors remain. $this->assertArrayHasKey( 'google', $connectors ); $this->assertArrayHasKey( 'anthropic', $connectors ); - - remove_all_filters( 'wp_connectors_settings' ); } /** - * @ticket 64730 + * @ticket 64791 */ public function test_filter_added_api_key_connector_gets_setting_name() { - add_filter( - 'wp_connectors_settings', - static function ( $connectors ) { - $connectors['custom_ai'] = array( - 'name' => 'Custom AI', - 'description' => 'A custom AI provider.', - 'type' => 'ai_provider', - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://example.com/keys', - ), - ); - return $connectors; - } - ); + $callback = static function ( $connectors ) { + $connectors['custom_ai'] = array( + 'name' => 'Custom AI', + 'description' => 'A custom AI provider.', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/keys', + ), + ); + return $connectors; + }; + add_filter( 'wp_connectors_settings', $callback ); $connectors = _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); $this->assertArrayHasKey( 'custom_ai', $connectors ); $this->assertSame( @@ -212,31 +202,28 @@ static function ( $connectors ) { $connectors['custom_ai']['authentication']['setting_name'], 'Connectors added via the filter with api_key auth should receive a setting_name automatically.' ); - - remove_all_filters( 'wp_connectors_settings' ); } /** - * @ticket 64730 + * @ticket 64791 */ public function test_filter_added_non_ai_api_key_connector_does_not_get_setting_name() { - add_filter( - 'wp_connectors_settings', - static function ( $connectors ) { - $connectors['my_crm'] = array( - 'name' => 'My CRM', - 'description' => 'CRM integration.', - 'type' => 'crm', - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://example.com/crm-keys', - ), - ); - return $connectors; - } - ); + $callback = static function ( $connectors ) { + $connectors['my_crm'] = array( + 'name' => 'My CRM', + 'description' => 'CRM integration.', + 'type' => 'crm', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/crm-keys', + ), + ); + return $connectors; + }; + add_filter( 'wp_connectors_settings', $callback ); $connectors = _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); $this->assertArrayHasKey( 'my_crm', $connectors ); $this->assertArrayNotHasKey( @@ -244,31 +231,26 @@ static function ( $connectors ) { $connectors['my_crm']['authentication'], 'Non-AI connectors should not receive an auto-generated setting_name.' ); - - remove_all_filters( 'wp_connectors_settings' ); } /** - * @ticket 64730 + * @ticket 64791 */ public function test_filter_receives_all_default_connectors() { $received = null; - add_filter( - 'wp_connectors_settings', - static function ( $connectors ) use ( &$received ) { - $received = $connectors; - return $connectors; - } - ); + $callback = static function ( $connectors ) use ( &$received ) { + $received = $connectors; + return $connectors; + }; + add_filter( 'wp_connectors_settings', $callback ); _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); $this->assertArrayHasKey( 'google', $received ); $this->assertArrayHasKey( 'openai', $received ); $this->assertArrayHasKey( 'anthropic', $received ); $this->assertArrayHasKey( 'mock_connectors_test', $received ); - - remove_all_filters( 'wp_connectors_settings' ); } } From e5ca2b357663774b8f9a24c470556146c1959c91 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 5 Mar 2026 13:44:47 +0100 Subject: [PATCH 03/22] Connectors: Add registry guards before registering settings and passing keys. Skip `register_setting` in `_wp_register_default_connector_settings()` when the AI provider is not in the registry, preventing REST-exposed settings that silently reject values. Reorder the `hasProvider` check in `_wp_connectors_pass_default_keys_to_ai_client()` to run before reading the option. Update the REST settings test to reflect that connector settings are only registered when their provider is active. Follow-up to [555bb8a833], [bd6c4c87ae]. See #64791. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 13 +++++++++++- .../rest-api/rest-settings-controller.php | 4 ---- tests/qunit/fixtures/wp-api-generated.js | 21 ------------------- 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index e4f2327b4794e..9b25f488d14d6 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -316,12 +316,19 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE * @access private */ function _wp_register_default_connector_settings(): void { + $registry = AiClient::defaultRegistry(); + 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'] ) ) { continue; } + // Skip registering the setting if the provider is not in the registry. + if ( ! $registry->hasProvider( $connector_id ) ) { + continue; + } + $setting_name = $auth['setting_name']; register_setting( 'connectors', @@ -375,8 +382,12 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { continue; } + if ( ! $registry->hasProvider( $connector_id ) ) { + continue; + } + $api_key = _wp_connectors_get_real_api_key( $auth['setting_name'], '_wp_connectors_mask_api_key' ); - if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) { + if ( '' === $api_key ) { continue; } diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index ef9e72e6a6724..dd79885d2b16d 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -120,10 +120,6 @@ 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 a8e8c6280600c..b9a143d12d95c 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11066,24 +11066,6 @@ mockedApiResponse.Schema = { "PATCH" ], "args": { - "connectors_ai_anthropic_api_key": { - "title": "Anthropic API Key", - "description": "API key for the Anthropic AI provider.", - "type": "string", - "required": false - }, - "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 - }, "title": { "title": "Title", "description": "Site title.", @@ -14762,9 +14744,6 @@ mockedApiResponse.CommentModel = { }; mockedApiResponse.settings = { - "connectors_ai_anthropic_api_key": "", - "connectors_ai_google_api_key": "", - "connectors_ai_openai_api_key": "", "title": "Test Blog", "description": "", "url": "http://example.org", From ccafc8d538310d09e1563bcede66552c30cf3065 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 6 Mar 2026 11:08:19 +0100 Subject: [PATCH 04/22] Connectors: Move `wp_connectors_settings` filter after `setting_name` generation. Addresses review feedback to filter the final, fully populated value. The filter now receives connectors with `setting_name` already set for API-key connectors, and runs as the last step before returning. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 21 +++--- .../wpConnectorsGetConnectorSettings.php | 65 ++----------------- 2 files changed, 17 insertions(+), 69 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 9b25f488d14d6..281db51fdb42b 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -200,6 +200,13 @@ function _wp_connectors_get_connector_settings(): array { } } + // Add setting_name for AI provider connectors that use API key authentication. + foreach ( $connectors as $connector_id => $connector ) { + if ( 'ai_provider' === $connector['type'] && 'api_key' === $connector['authentication']['method'] ) { + $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; + } + } + ksort( $connectors ); /** @@ -207,8 +214,8 @@ function _wp_connectors_get_connector_settings(): array { * * Allows plugins to add, modify, or remove connectors displayed * on the Connectors screen. Runs after built-in and AI Client - * registry providers are collected, but before `setting_name` is - * generated for API-key connectors. + * registry providers are collected and fully populated with + * `setting_name` for API-key connectors. * * @since 7.0.0 * @@ -226,23 +233,17 @@ function _wp_connectors_get_connector_settings(): array { * } * @type array $authentication { * Authentication configuration. When method is 'api_key', includes - * credentials_url. When 'none', only method is present. + * 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. Present when method is 'api_key'. * } * } * } */ $connectors = apply_filters( 'wp_connectors_settings', $connectors ); - // Add setting_name for AI provider connectors that use API key authentication. - foreach ( $connectors as $connector_id => $connector ) { - if ( 'ai_provider' === $connector['type'] && 'api_key' === $connector['authentication']['method'] ) { - $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; - } - } - return $connectors; } diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index e2bc42c2ebdeb..54193acb10762 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -178,65 +178,7 @@ public function test_filter_can_remove_connector() { /** * @ticket 64791 */ - public function test_filter_added_api_key_connector_gets_setting_name() { - $callback = static function ( $connectors ) { - $connectors['custom_ai'] = array( - 'name' => 'Custom AI', - 'description' => 'A custom AI provider.', - 'type' => 'ai_provider', - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://example.com/keys', - ), - ); - return $connectors; - }; - add_filter( 'wp_connectors_settings', $callback ); - - $connectors = _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); - - $this->assertArrayHasKey( 'custom_ai', $connectors ); - $this->assertSame( - 'connectors_ai_custom_ai_api_key', - $connectors['custom_ai']['authentication']['setting_name'], - 'Connectors added via the filter with api_key auth should receive a setting_name automatically.' - ); - } - - /** - * @ticket 64791 - */ - public function test_filter_added_non_ai_api_key_connector_does_not_get_setting_name() { - $callback = static function ( $connectors ) { - $connectors['my_crm'] = array( - 'name' => 'My CRM', - 'description' => 'CRM integration.', - 'type' => 'crm', - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://example.com/crm-keys', - ), - ); - return $connectors; - }; - add_filter( 'wp_connectors_settings', $callback ); - - $connectors = _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); - - $this->assertArrayHasKey( 'my_crm', $connectors ); - $this->assertArrayNotHasKey( - 'setting_name', - $connectors['my_crm']['authentication'], - 'Non-AI connectors should not receive an auto-generated setting_name.' - ); - } - - /** - * @ticket 64791 - */ - public function test_filter_receives_all_default_connectors() { + public function test_filter_receives_all_default_connectors_with_setting_name() { $received = null; $callback = static function ( $connectors ) use ( &$received ) { @@ -252,5 +194,10 @@ public function test_filter_receives_all_default_connectors() { $this->assertArrayHasKey( 'openai', $received ); $this->assertArrayHasKey( 'anthropic', $received ); $this->assertArrayHasKey( 'mock_connectors_test', $received ); + + // The filter receives fully populated data, including setting_name for API-key connectors. + $this->assertSame( 'connectors_ai_openai_api_key', $received['openai']['authentication']['setting_name'] ); + $this->assertSame( 'connectors_ai_anthropic_api_key', $received['anthropic']['authentication']['setting_name'] ); + $this->assertSame( 'connectors_ai_google_api_key', $received['google']['authentication']['setting_name'] ); } } From 0a93a5fe51248425bb1e109f0874887859c34098 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 6 Mar 2026 11:09:28 +0100 Subject: [PATCH 05/22] Connectors: Add test for filter returning empty array. Co-Authored-By: Claude Opus 4.6 --- .../wpConnectorsGetConnectorSettings.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index 54193acb10762..506bbc04a1e9a 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -200,4 +200,19 @@ public function test_filter_receives_all_default_connectors_with_setting_name() $this->assertSame( 'connectors_ai_anthropic_api_key', $received['anthropic']['authentication']['setting_name'] ); $this->assertSame( 'connectors_ai_google_api_key', $received['google']['authentication']['setting_name'] ); } + + /** + * @ticket 64791 + */ + public function test_filter_can_return_empty_array() { + $callback = static function () { + return array(); + }; + add_filter( 'wp_connectors_settings', $callback ); + + $connectors = _wp_connectors_get_connector_settings(); + remove_filter( 'wp_connectors_settings', $callback ); + + $this->assertSame( array(), $connectors ); + } } From c0736e592d591c0ad2112715d167e72cf5869e83 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 6 Mar 2026 11:39:42 +0100 Subject: [PATCH 06/22] Connectors: Replace filter with WP_Connector_Registry and action-based registration. Introduces `WP_Connector_Registry` class with `register()`, `unregister()`, `is_registered()`, `get_registered()`, and `get_all_registered()` methods. Adds public API functions: `wp_register_connector()`, `wp_unregister_connector()`, `wp_has_connector()`, `wp_get_connector()`, `wp_get_connectors()`. Connectors must be registered on the `wp_connectors_init` action hook. Default connectors (Anthropic, Google, OpenAI) and AI Client registry providers are registered via `_wp_register_default_connectors()`. Removes the `wp_connectors_settings` filter in favor of the registry pattern, consistent with `WP_Abilities_Registry` and other Core registration APIs. Co-Authored-By: Claude Opus 4.6 --- .../class-wp-connector-registry.php | 298 ++++++++++++++ src/wp-includes/connectors.php | 377 ++++++++++++------ src/wp-includes/default-filters.php | 3 + src/wp-settings.php | 1 + .../wp-ai-client-mock-provider-trait.php | 23 +- .../tests/connectors/wpConnectorRegistry.php | 340 ++++++++++++++++ .../wpConnectorsGetConnectorSettings.php | 100 +---- .../tests/connectors/wpRegisterConnector.php | 157 ++++++++ 8 files changed, 1070 insertions(+), 229 deletions(-) create mode 100644 src/wp-includes/class-wp-connector-registry.php create mode 100644 tests/phpunit/tests/connectors/wpConnectorRegistry.php create mode 100644 tests/phpunit/tests/connectors/wpRegisterConnector.php diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php new file mode 100644 index 0000000000000..5f5e0ed93ea62 --- /dev/null +++ b/src/wp-includes/class-wp-connector-registry.php @@ -0,0 +1,298 @@ +is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" is already registered.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + // Validate required fields. + if ( empty( $args['name'] ) || ! is_string( $args['name'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires a non-empty "name" string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( empty( $args['type'] ) || ! is_string( $args['type'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires a non-empty "type" string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( ! isset( $args['authentication'] ) || ! is_array( $args['authentication'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires an "authentication" array.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( empty( $args['authentication']['method'] ) || ! in_array( $args['authentication']['method'], array( 'api_key', 'none' ), true ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" authentication method must be "api_key" or "none".' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + $connector = array( + 'name' => $args['name'], + 'description' => isset( $args['description'] ) && is_string( $args['description'] ) ? $args['description'] : '', + 'type' => $args['type'], + 'authentication' => array( + 'method' => $args['authentication']['method'], + ), + ); + + if ( 'api_key' === $args['authentication']['method'] ) { + $connector['authentication']['credentials_url'] = isset( $args['authentication']['credentials_url'] ) ? $args['authentication']['credentials_url'] : null; + $connector['authentication']['setting_name'] = "connectors_ai_{$id}_api_key"; + } + + if ( ! empty( $args['plugin'] ) && is_array( $args['plugin'] ) ) { + $connector['plugin'] = $args['plugin']; + } + + $this->registered_connectors[ $id ] = $connector; + return $connector; + } + + /** + * Unregisters a connector. + * + * Do not use this method directly. Instead, use the `wp_unregister_connector()` function. + * + * @since 7.0.0 + * + * @see wp_unregister_connector() + * + * @param string $id The connector identifier. + * @return array|null The unregistered connector data on success, null on failure. + */ + public function unregister( string $id ): ?array { + if ( ! $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + $unregistered = $this->registered_connectors[ $id ]; + unset( $this->registered_connectors[ $id ] ); + + return $unregistered; + } + + /** + * Retrieves the list of all registered connectors. + * + * Do not use this method directly. Instead, use the `wp_get_connectors()` function. + * + * @since 7.0.0 + * + * @see wp_get_connectors() + * + * @return array[] The array of registered connectors keyed by connector ID. + */ + public function get_all_registered(): array { + return $this->registered_connectors; + } + + /** + * Checks if a connector is registered. + * + * Do not use this method directly. Instead, use the `wp_has_connector()` function. + * + * @since 7.0.0 + * + * @see wp_has_connector() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. + */ + public function is_registered( string $id ): bool { + return isset( $this->registered_connectors[ $id ] ); + } + + /** + * Retrieves a registered connector. + * + * Do not use this method directly. Instead, use the `wp_get_connector()` function. + * + * @since 7.0.0 + * + * @see wp_get_connector() + * + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if it is not registered. + */ + public function get_registered( string $id ): ?array { + if ( ! $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + return $this->registered_connectors[ $id ]; + } + + /** + * Utility method to retrieve the main instance of the registry class. + * + * The instance will be created if it does not exist yet. + * + * @since 7.0.0 + * + * @return WP_Connector_Registry|null The main registry instance, or null when `init` action has not fired. + */ + public static function get_instance(): ?self { + if ( ! did_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: init action. */ + __( 'Connector registry should not be initialized before the %s action has fired.' ), + 'init' + ), + '7.0.0' + ); + return null; + } + + if ( null === self::$instance ) { + self::$instance = new self(); + + /** + * Fires when preparing connector registry. + * + * Connectors should be registered on this action rather + * than another action to ensure they're only loaded when needed. + * + * @since 7.0.0 + * + * @param WP_Connector_Registry $instance Connector registry object. + */ + do_action( 'wp_connectors_init', self::$instance ); + } + + return self::$instance; + } + + /** + * Wakeup magic method. + * + * @since 7.0.0 + * @throws LogicException If the registry object is unserialized. + */ + public function __wakeup(): void { + throw new LogicException( __CLASS__ . ' should never be unserialized.' ); + } + + /** + * Sleep magic method. + * + * @since 7.0.0 + * @throws LogicException If the registry object is serialized. + */ + public function __sleep(): array { + throw new LogicException( __CLASS__ . ' should never be serialized.' ); + } +} diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 281db51fdb42b..9caf8954b89da 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -10,6 +10,245 @@ use WordPress\AiClient\AiClient; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; +/** + * Registers a new connector. + * + * Must be called during the `wp_connectors_init` action. + * + * Example: + * + * function my_plugin_register_connectors( WP_Connector_Registry $registry ): void { + * wp_register_connector( + * 'my_custom_ai', + * array( + * 'name' => __( 'My Custom AI', 'my-plugin' ), + * 'description' => __( 'Custom AI provider integration.', 'my-plugin' ), + * 'type' => 'ai_provider', + * 'authentication' => array( + * 'method' => 'api_key', + * 'credentials_url' => 'https://example.com/api-keys', + * ), + * ) + * ); + * } + * add_action( 'wp_connectors_init', 'my_plugin_register_connectors' ); + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::register() + * + * @param string $id The unique connector identifier. Must contain only lowercase + * alphanumeric characters and underscores. + * @param array $args { + * An associative array of arguments for the connector. + * + * @type string $name Required. The connector's display name. + * @type string $description Optional. The connector's description. Default empty string. + * @type string $type Required. The connector type. Currently, only 'ai_provider' is supported. + * @type array $authentication { + * Required. Authentication configuration. + * + * @type string $method Required. The authentication method: 'api_key' or 'none'. + * @type string|null $credentials_url Optional. URL where users can obtain API credentials. + * } + * @type array $plugin Optional. Plugin data for install/activate UI. + * @type string $slug The WordPress.org plugin slug. + * } + * } + * @return array|null The registered connector data on success, null on failure. + */ +function wp_register_connector( string $id, array $args ): ?array { + if ( ! doing_action( 'wp_connectors_init' ) ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: 1: wp_connectors_init, 2: string value of the connector ID. */ + __( 'Connectors must be registered on the %1$s action. The connector %2$s was not registered.' ), + 'wp_connectors_init', + '' . esc_html( $id ) . '' + ), + '7.0.0' + ); + return null; + } + + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->register( $id, $args ); +} + +/** + * Unregisters a connector. + * + * Can be called at any time after the connector has been registered. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::unregister() + * @see wp_register_connector() + * + * @param string $id The connector identifier. + * @return array|null The unregistered connector data on success, null on failure. + */ +function wp_unregister_connector( string $id ): ?array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->unregister( $id ); +} + +/** + * Checks if a connector is registered. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::is_registered() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. + */ +function wp_has_connector( string $id ): bool { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return false; + } + + return $registry->is_registered( $id ); +} + +/** + * Retrieves a registered connector. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::get_registered() + * + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if not registered. + */ +function wp_get_connector( string $id ): ?array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $id ); +} + +/** + * Retrieves all registered connectors. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::get_all_registered() + * + * @return array[] An array of registered connectors keyed by connector ID. + */ +function wp_get_connectors(): array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); +} + +/** + * Registers default connectors from Core and the AI Client registry. + * + * @since 7.0.0 + * @access private + */ +function _wp_register_default_connectors(): void { + // Built-in connectors. + $defaults = array( + 'anthropic' => array( + 'name' => 'Anthropic', + 'description' => __( 'Text generation with Claude.' ), + 'type' => 'ai_provider', + 'plugin' => array( + 'slug' => 'ai-provider-for-anthropic', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://platform.claude.com/settings/keys', + ), + ), + 'google' => array( + 'name' => 'Google', + 'description' => __( 'Text and image generation with Gemini and Imagen.' ), + 'type' => 'ai_provider', + 'plugin' => array( + 'slug' => 'ai-provider-for-google', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://aistudio.google.com/api-keys', + ), + ), + 'openai' => array( + 'name' => 'OpenAI', + 'description' => __( 'Text and image generation with GPT and Dall-E.' ), + 'type' => 'ai_provider', + 'plugin' => array( + 'slug' => 'ai-provider-for-openai', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://platform.openai.com/api-keys', + ), + ), + ); + + // Register built-in connectors first. + foreach ( $defaults as $id => $args ) { + wp_register_connector( $id, $args ); + } + + // Register connectors from the AI Client registry. + $ai_registry = AiClient::defaultRegistry(); + + foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) { + $provider_class_name = $ai_registry->getProviderClassName( $connector_id ); + $provider_metadata = $provider_class_name::metadata(); + + $auth_method = $provider_metadata->getAuthenticationMethod(); + $is_api_key = null !== $auth_method && $auth_method->isApiKey(); + + if ( $is_api_key ) { + $credentials_url = $provider_metadata->getCredentialsUrl(); + $authentication = array( + 'method' => 'api_key', + 'credentials_url' => $credentials_url ? $credentials_url : null, + ); + } else { + $authentication = array( 'method' => 'none' ); + } + + $name = $provider_metadata->getName(); + $description = $provider_metadata->getDescription(); + + if ( wp_has_connector( $connector_id ) ) { + // Already registered as a built-in; skip to avoid duplicate registration error. + continue; + } + + wp_register_connector( + $connector_id, + array( + 'name' => $name ? $name : ucwords( $connector_id ), + 'description' => $description ? $description : '', + 'type' => 'ai_provider', + 'authentication' => $authentication, + ) + ); + } +} /** * Masks an API key, showing only the last 4 characters. @@ -116,134 +355,8 @@ function _wp_connectors_get_real_api_key( string $option_name, callable $mask_ca * } */ function _wp_connectors_get_connector_settings(): array { - $connectors = array( - 'anthropic' => array( - 'name' => 'Anthropic', - 'description' => __( 'Text generation with Claude.' ), - 'type' => 'ai_provider', - 'plugin' => array( - 'slug' => 'ai-provider-for-anthropic', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://platform.claude.com/settings/keys', - ), - ), - 'google' => array( - 'name' => 'Google', - 'description' => __( 'Text and image generation with Gemini and Imagen.' ), - 'type' => 'ai_provider', - 'plugin' => array( - 'slug' => 'ai-provider-for-google', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://aistudio.google.com/api-keys', - ), - ), - 'openai' => array( - 'name' => 'OpenAI', - 'description' => __( 'Text and image generation with GPT and Dall-E.' ), - 'type' => 'ai_provider', - 'plugin' => array( - 'slug' => 'ai-provider-for-openai', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://platform.openai.com/api-keys', - ), - ), - ); - - $registry = AiClient::defaultRegistry(); - - foreach ( $registry->getRegisteredProviderIds() as $connector_id ) { - $provider_class_name = $registry->getProviderClassName( $connector_id ); - $provider_metadata = $provider_class_name::metadata(); - - $auth_method = $provider_metadata->getAuthenticationMethod(); - $is_api_key = null !== $auth_method && $auth_method->isApiKey(); - - if ( $is_api_key ) { - $credentials_url = $provider_metadata->getCredentialsUrl(); - $authentication = array( - 'method' => 'api_key', - 'credentials_url' => $credentials_url ? $credentials_url : null, - ); - } else { - $authentication = array( 'method' => 'none' ); - } - - $name = $provider_metadata->getName(); - $description = $provider_metadata->getDescription(); - - if ( isset( $connectors[ $connector_id ] ) ) { - // Override fields with non-empty registry values. - if ( $name ) { - $connectors[ $connector_id ]['name'] = $name; - } - if ( $description ) { - $connectors[ $connector_id ]['description'] = $description; - } - // Always update auth method; keep existing credentials_url as fallback. - $connectors[ $connector_id ]['authentication']['method'] = $authentication['method']; - if ( ! empty( $authentication['credentials_url'] ) ) { - $connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; - } - } else { - $connectors[ $connector_id ] = array( - 'name' => $name ? $name : ucwords( $connector_id ), - 'description' => $description ? $description : '', - 'type' => 'ai_provider', - 'authentication' => $authentication, - ); - } - } - - // Add setting_name for AI provider connectors that use API key authentication. - foreach ( $connectors as $connector_id => $connector ) { - if ( 'ai_provider' === $connector['type'] && 'api_key' === $connector['authentication']['method'] ) { - $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; - } - } - + $connectors = wp_get_connectors(); ksort( $connectors ); - - /** - * Filters the registered connector settings. - * - * Allows plugins to add, modify, or remove connectors displayed - * on the Connectors screen. Runs after built-in and AI Client - * registry providers are collected and fully populated with - * `setting_name` for API-key connectors. - * - * @since 7.0.0 - * - * @param array $connectors { - * 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. Present when method is 'api_key'. - * } - * } - * } - */ - $connectors = apply_filters( 'wp_connectors_settings', $connectors ); - return $connectors; } @@ -317,7 +430,7 @@ function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_RE * @access private */ function _wp_register_default_connector_settings(): void { - $registry = AiClient::defaultRegistry(); + $ai_registry = AiClient::defaultRegistry(); foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; @@ -326,7 +439,7 @@ function _wp_register_default_connector_settings(): void { } // Skip registering the setting if the provider is not in the registry. - if ( ! $registry->hasProvider( $connector_id ) ) { + if ( ! $ai_registry->hasProvider( $connector_id ) ) { continue; } @@ -372,7 +485,7 @@ function _wp_register_default_connector_settings(): void { */ function _wp_connectors_pass_default_keys_to_ai_client(): void { try { - $registry = AiClient::defaultRegistry(); + $ai_registry = AiClient::defaultRegistry(); foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { if ( 'ai_provider' !== $connector_data['type'] ) { continue; @@ -383,7 +496,7 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { continue; } - if ( ! $registry->hasProvider( $connector_id ) ) { + if ( ! $ai_registry->hasProvider( $connector_id ) ) { continue; } @@ -392,7 +505,7 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { continue; } - $registry->setProviderRequestAuthentication( + $ai_registry->setProviderRequestAuthentication( $connector_id, new ApiKeyRequestAuthentication( $api_key ) ); diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 796cf00ec81e1..1f7ba06f3102b 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -539,6 +539,9 @@ add_action( 'wp_abilities_api_categories_init', 'wp_register_core_ability_categories' ); add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' ); +// Connectors API. +add_action( 'wp_connectors_init', '_wp_register_default_connectors' ); + // Sitemaps actions. add_action( 'init', 'wp_sitemaps_get_server' ); diff --git a/src/wp-settings.php b/src/wp-settings.php index 023cdccd5ecc9..dab1d8fd4c0de 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 . '/class-wp-connector-registry.php'; require ABSPATH . WPINC . '/connectors.php'; require ABSPATH . WPINC . '/class-wp-icons-registry.php'; require ABSPATH . WPINC . '/widgets.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 index 9797017451e0d..e7637bf239119 100644 --- a/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php +++ b/tests/phpunit/includes/wp-ai-client-mock-provider-trait.php @@ -155,9 +155,26 @@ trait WP_AI_Client_Mock_Provider_Trait { * Must be called from set_up_before_class() after parent::set_up_before_class(). */ private static function register_mock_connectors_provider(): void { - $registry = AiClient::defaultRegistry(); - if ( ! $registry->hasProvider( 'mock_connectors_test' ) ) { - $registry->registerProvider( Mock_Connectors_Test_Provider::class ); + $ai_registry = AiClient::defaultRegistry(); + if ( ! $ai_registry->hasProvider( 'mock_connectors_test' ) ) { + $ai_registry->registerProvider( Mock_Connectors_Test_Provider::class ); + } + + // Also register in the WP connector registry if not already present. + $connector_registry = WP_Connector_Registry::get_instance(); + if ( null !== $connector_registry && ! $connector_registry->is_registered( 'mock_connectors_test' ) ) { + $connector_registry->register( + 'mock_connectors_test', + array( + 'name' => 'Mock Connectors Test', + 'description' => '', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => null, + ), + ) + ); } } diff --git a/tests/phpunit/tests/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php new file mode 100644 index 0000000000000..2d82d3fdf9604 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php @@ -0,0 +1,340 @@ +registry = new WP_Connector_Registry(); + + self::$default_args = array( + 'name' => 'Test Provider', + 'description' => 'A test AI provider.', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/keys', + ), + ); + } + + /** + * @ticket 64791 + */ + public function test_register_returns_connector_data() { + $result = $this->registry->register( 'test_provider', self::$default_args ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Provider', $result['name'] ); + $this->assertSame( 'A test AI provider.', $result['description'] ); + $this->assertSame( 'ai_provider', $result['type'] ); + $this->assertSame( 'api_key', $result['authentication']['method'] ); + $this->assertSame( 'https://example.com/keys', $result['authentication']['credentials_url'] ); + $this->assertSame( 'connectors_ai_test_provider_api_key', $result['authentication']['setting_name'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_generates_setting_name_for_api_key() { + $result = $this->registry->register( 'my_ai', self::$default_args ); + + $this->assertSame( 'connectors_ai_my_ai_api_key', $result['authentication']['setting_name'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_no_setting_name_for_none_auth() { + $args = array( + 'name' => 'No Auth Provider', + 'type' => 'ai_provider', + 'authentication' => array( 'method' => 'none' ), + ); + $result = $this->registry->register( 'no_auth', $args ); + + $this->assertIsArray( $result ); + $this->assertArrayNotHasKey( 'setting_name', $result['authentication'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_defaults_description_to_empty_string() { + $args = array( + 'name' => 'Minimal', + 'type' => 'ai_provider', + 'authentication' => array( 'method' => 'none' ), + ); + + $result = $this->registry->register( 'minimal', $args ); + + $this->assertSame( '', $result['description'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_includes_plugin_data() { + $args = self::$default_args; + $args['plugin'] = array( 'slug' => 'my-plugin' ); + + $result = $this->registry->register( 'with_plugin', $args ); + + $this->assertArrayHasKey( 'plugin', $result ); + $this->assertSame( array( 'slug' => 'my-plugin' ), $result['plugin'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_omits_plugin_when_not_provided() { + $result = $this->registry->register( 'no_plugin', self::$default_args ); + + $this->assertArrayNotHasKey( 'plugin', $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_invalid_id_with_uppercase() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $result = $this->registry->register( 'InvalidId', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_invalid_id_with_dashes() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $result = $this->registry->register( 'my-provider', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_empty_id() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $result = $this->registry->register( '', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_duplicate_id() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $this->registry->register( 'duplicate', self::$default_args ); + $result = $this->registry->register( 'duplicate', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_missing_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + unset( $args['name'] ); + + $result = $this->registry->register( 'no_name', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_empty_name() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['name'] = ''; + + $result = $this->registry->register( 'empty_name', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_missing_type() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + unset( $args['type'] ); + + $result = $this->registry->register( 'no_type', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_missing_authentication() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + unset( $args['authentication'] ); + + $result = $this->registry->register( 'no_auth', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_rejects_invalid_auth_method() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::register' ); + + $args = self::$default_args; + $args['authentication']['method'] = 'oauth'; + + $result = $this->registry->register( 'bad_auth', $args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_is_registered_returns_true_for_registered() { + $this->registry->register( 'exists', self::$default_args ); + + $this->assertTrue( $this->registry->is_registered( 'exists' ) ); + } + + /** + * @ticket 64791 + */ + public function test_is_registered_returns_false_for_unregistered() { + $this->assertFalse( $this->registry->is_registered( 'does_not_exist' ) ); + } + + /** + * @ticket 64791 + */ + public function test_get_registered_returns_connector_data() { + $this->registry->register( 'my_connector', self::$default_args ); + + $result = $this->registry->get_registered( 'my_connector' ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Provider', $result['name'] ); + } + + /** + * @ticket 64791 + */ + public function test_get_registered_returns_null_for_unregistered() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::get_registered' ); + + $result = $this->registry->get_registered( 'nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_get_all_registered_returns_all_connectors() { + $this->registry->register( 'first', self::$default_args ); + + $args = self::$default_args; + $args['name'] = 'Second Provider'; + $this->registry->register( 'second', $args ); + + $all = $this->registry->get_all_registered(); + + $this->assertCount( 2, $all ); + $this->assertArrayHasKey( 'first', $all ); + $this->assertArrayHasKey( 'second', $all ); + } + + /** + * @ticket 64791 + */ + public function test_get_all_registered_returns_empty_when_none() { + $this->assertSame( array(), $this->registry->get_all_registered() ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_removes_connector() { + $this->registry->register( 'to_remove', self::$default_args ); + + $result = $this->registry->unregister( 'to_remove' ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Provider', $result['name'] ); + $this->assertFalse( $this->registry->is_registered( 'to_remove' ) ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_returns_null_for_unregistered() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::unregister' ); + + $result = $this->registry->unregister( 'nonexistent' ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_get_instance_returns_registry() { + $instance = WP_Connector_Registry::get_instance(); + + $this->assertInstanceOf( WP_Connector_Registry::class, $instance ); + } + + /** + * @ticket 64791 + */ + public function test_get_instance_returns_same_instance() { + $instance1 = WP_Connector_Registry::get_instance(); + $instance2 = WP_Connector_Registry::get_instance(); + + $this->assertSame( $instance1, $instance2 ); + } +} diff --git a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php index 506bbc04a1e9a..2a7ce199fa777 100644 --- a/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php +++ b/tests/phpunit/tests/connectors/wpConnectorsGetConnectorSettings.php @@ -117,102 +117,14 @@ public function test_includes_registered_provider_from_registry() { } /** - * @ticket 64791 - */ - public function test_filter_can_add_new_connector() { - $callback = static function ( $connectors ) { - $connectors['my_email_service'] = array( - 'name' => 'My Email Service', - 'description' => 'Send transactional emails.', - 'type' => 'email_service', - 'authentication' => array( 'method' => 'none' ), - ); - return $connectors; - }; - add_filter( 'wp_connectors_settings', $callback ); - - $connectors = _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); - - $this->assertArrayHasKey( 'my_email_service', $connectors ); - $this->assertSame( 'My Email Service', $connectors['my_email_service']['name'] ); - $this->assertSame( 'email_service', $connectors['my_email_service']['type'] ); - $this->assertSame( 'none', $connectors['my_email_service']['authentication']['method'] ); - } - - /** - * @ticket 64791 - */ - public function test_filter_can_modify_existing_connector() { - $callback = static function ( $connectors ) { - $connectors['google']['description'] = 'Custom description for Google.'; - return $connectors; - }; - add_filter( 'wp_connectors_settings', $callback ); - - $connectors = _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); - - $this->assertSame( 'Custom description for Google.', $connectors['google']['description'] ); - } - - /** - * @ticket 64791 - */ - public function test_filter_can_remove_connector() { - $callback = static function ( $connectors ) { - unset( $connectors['openai'] ); - return $connectors; - }; - add_filter( 'wp_connectors_settings', $callback ); - - $connectors = _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); - - $this->assertArrayNotHasKey( 'openai', $connectors ); - // Other connectors remain. - $this->assertArrayHasKey( 'google', $connectors ); - $this->assertArrayHasKey( 'anthropic', $connectors ); - } - - /** - * @ticket 64791 - */ - public function test_filter_receives_all_default_connectors_with_setting_name() { - $received = null; - - $callback = static function ( $connectors ) use ( &$received ) { - $received = $connectors; - return $connectors; - }; - add_filter( 'wp_connectors_settings', $callback ); - - _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); - - $this->assertArrayHasKey( 'google', $received ); - $this->assertArrayHasKey( 'openai', $received ); - $this->assertArrayHasKey( 'anthropic', $received ); - $this->assertArrayHasKey( 'mock_connectors_test', $received ); - - // The filter receives fully populated data, including setting_name for API-key connectors. - $this->assertSame( 'connectors_ai_openai_api_key', $received['openai']['authentication']['setting_name'] ); - $this->assertSame( 'connectors_ai_anthropic_api_key', $received['anthropic']['authentication']['setting_name'] ); - $this->assertSame( 'connectors_ai_google_api_key', $received['google']['authentication']['setting_name'] ); - } - - /** - * @ticket 64791 + * @ticket 64730 */ - public function test_filter_can_return_empty_array() { - $callback = static function () { - return array(); - }; - add_filter( 'wp_connectors_settings', $callback ); - + public function test_connectors_are_sorted_alphabetically() { $connectors = _wp_connectors_get_connector_settings(); - remove_filter( 'wp_connectors_settings', $callback ); + $keys = array_keys( $connectors ); + $sorted = $keys; + sort( $sorted ); - $this->assertSame( array(), $connectors ); + $this->assertSame( $sorted, $keys, 'Connectors should be sorted alphabetically by ID.' ); } } diff --git a/tests/phpunit/tests/connectors/wpRegisterConnector.php b/tests/phpunit/tests/connectors/wpRegisterConnector.php new file mode 100644 index 0000000000000..1e05a68a58609 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpRegisterConnector.php @@ -0,0 +1,157 @@ + 'Test Connector', + 'description' => 'A test connector.', + 'type' => 'ai_provider', + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://example.com/keys', + ), + ); + } + + /** + * Helper to simulate the wp_connectors_init action for registration. + * + * @param callable $callback The registration callback to run. + */ + private function simulate_doing_wp_connectors_init_action( callable $callback ): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_connectors_init'; + $callback(); + array_pop( $wp_current_filter ); + } + + /** + * @ticket 64791 + */ + public function test_register_fails_outside_action() { + $this->setExpectedIncorrectUsage( 'wp_register_connector' ); + + $result = wp_register_connector( 'outside_action', self::$default_args ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_succeeds_during_action() { + $result = null; + + $this->simulate_doing_wp_connectors_init_action( + function () use ( &$result ) { + $result = wp_register_connector( 'during_action', self::$default_args ); + } + ); + + $this->assertIsArray( $result ); + $this->assertSame( 'Test Connector', $result['name'] ); + } + + /** + * @ticket 64791 + */ + public function test_has_connector_returns_true_for_default() { + // Default connectors are registered via wp_connectors_init. + $this->assertTrue( wp_has_connector( 'openai' ) ); + $this->assertTrue( wp_has_connector( 'google' ) ); + $this->assertTrue( wp_has_connector( 'anthropic' ) ); + } + + /** + * @ticket 64791 + */ + public function test_has_connector_returns_false_for_unregistered() { + $this->assertFalse( wp_has_connector( 'nonexistent_provider' ) ); + } + + /** + * @ticket 64791 + */ + public function test_get_connector_returns_data_for_default() { + $connector = wp_get_connector( 'openai' ); + + $this->assertIsArray( $connector ); + $this->assertSame( 'OpenAI', $connector['name'] ); + $this->assertSame( 'ai_provider', $connector['type'] ); + $this->assertSame( 'api_key', $connector['authentication']['method'] ); + $this->assertSame( 'connectors_ai_openai_api_key', $connector['authentication']['setting_name'] ); + } + + /** + * @ticket 64791 + */ + public function test_get_connector_returns_null_for_unregistered() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::get_registered' ); + + $result = wp_get_connector( 'nonexistent_provider' ); + + $this->assertNull( $result ); + } + + /** + * @ticket 64791 + */ + public function test_get_connectors_returns_all_defaults() { + $connectors = wp_get_connectors(); + + $this->assertArrayHasKey( 'openai', $connectors ); + $this->assertArrayHasKey( 'google', $connectors ); + $this->assertArrayHasKey( 'anthropic', $connectors ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_removes_connector() { + $this->simulate_doing_wp_connectors_init_action( + function () { + wp_register_connector( 'to_remove', self::$default_args ); + } + ); + + $this->assertTrue( wp_has_connector( 'to_remove' ) ); + + $result = wp_unregister_connector( 'to_remove' ); + + $this->assertIsArray( $result ); + $this->assertFalse( wp_has_connector( 'to_remove' ) ); + } + + /** + * @ticket 64791 + */ + public function test_unregister_returns_null_for_nonexistent() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::unregister' ); + + $result = wp_unregister_connector( 'nonexistent' ); + + $this->assertNull( $result ); + } +} From b733ffc2696abda97cd2393b5ecce6d240aec2aa Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 6 Mar 2026 11:55:06 +0100 Subject: [PATCH 07/22] Connectors: Preserve AI Client registry data precedence over hardcoded defaults. Merges AI Client registry values on top of hardcoded defaults before registering, so provider plugin data takes precedence while hardcoded values serve as fallbacks. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 40 ++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 9caf8954b89da..fb05b13d044be 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -205,12 +205,8 @@ function _wp_register_default_connectors(): void { ), ); - // Register built-in connectors first. - foreach ( $defaults as $id => $args ) { - wp_register_connector( $id, $args ); - } - - // Register connectors from the AI Client registry. + // Merge AI Client registry data on top of defaults. + // Registry values (from provider plugins) take precedence over hardcoded fallbacks. $ai_registry = AiClient::defaultRegistry(); foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) { @@ -233,20 +229,32 @@ function _wp_register_default_connectors(): void { $name = $provider_metadata->getName(); $description = $provider_metadata->getDescription(); - if ( wp_has_connector( $connector_id ) ) { - // Already registered as a built-in; skip to avoid duplicate registration error. - continue; - } - - wp_register_connector( - $connector_id, - array( + if ( isset( $defaults[ $connector_id ] ) ) { + // Override fields with non-empty registry values. + if ( $name ) { + $defaults[ $connector_id ]['name'] = $name; + } + if ( $description ) { + $defaults[ $connector_id ]['description'] = $description; + } + // Always update auth method; keep existing credentials_url as fallback. + $defaults[ $connector_id ]['authentication']['method'] = $authentication['method']; + if ( ! empty( $authentication['credentials_url'] ) ) { + $defaults[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; + } + } else { + $defaults[ $connector_id ] = array( 'name' => $name ? $name : ucwords( $connector_id ), 'description' => $description ? $description : '', 'type' => 'ai_provider', 'authentication' => $authentication, - ) - ); + ); + } + } + + // Register all connectors. + foreach ( $defaults as $id => $args ) { + wp_register_connector( $id, $args ); } } From f07ab82934e02e9aace683b461853f05dab53e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Mon, 9 Mar 2026 08:23:24 +0100 Subject: [PATCH 08/22] Apply suggestions from code review Co-authored-by: Weston Ruter --- .../class-wp-connector-registry.php | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index 5f5e0ed93ea62..d62368f742005 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -14,6 +14,8 @@ * * @since 7.0.0 * @access private + * + * @phpstan-type Connector array{ name: string, description: string, type: string, authentication: array{ method: string, credentials_url?: string|null }, plugin?: array{ slug: string } } */ final class WP_Connector_Registry { /** @@ -22,7 +24,7 @@ final class WP_Connector_Registry { * @since 7.0.0 * @var self|null */ - private static $instance = null; + private static ?WP_Connector_Registry $instance = null; /** * Holds the registered connectors. @@ -31,9 +33,10 @@ final class WP_Connector_Registry { * name, description, type, authentication, and optionally plugin. * * @since 7.0.0 - * @var array[] + * @var array + * @phpstan-var array */ - private $registered_connectors = array(); + private array $registered_connectors = array(); /** * Registers a new connector. @@ -63,6 +66,9 @@ final class WP_Connector_Registry { * } * } * @return array|null The registered connector data on success, null on failure. + * + * @phpstan-param Connector $args + * @phpstan-return Connector|null */ public function register( string $id, array $args ): ?array { if ( ! preg_match( '/^[a-z0-9_]+$/', $id ) ) { @@ -137,7 +143,7 @@ public function register( string $id, array $args ): ?array { ); if ( 'api_key' === $args['authentication']['method'] ) { - $connector['authentication']['credentials_url'] = isset( $args['authentication']['credentials_url'] ) ? $args['authentication']['credentials_url'] : null; + $connector['authentication']['credentials_url'] = $args['authentication']['credentials_url'] ?? null; $connector['authentication']['setting_name'] = "connectors_ai_{$id}_api_key"; } @@ -160,6 +166,8 @@ public function register( string $id, array $args ): ?array { * * @param string $id The connector identifier. * @return array|null The unregistered connector data on success, null on failure. + * + * @phpstan-return Connector|null */ public function unregister( string $id ): ?array { if ( ! $this->is_registered( $id ) ) { @@ -187,7 +195,8 @@ public function unregister( string $id ): ?array { * * @see wp_get_connectors() * - * @return array[] The array of registered connectors keyed by connector ID. + * @return array The array of registered connectors keyed by connector ID. + * @phpstan-return array */ public function get_all_registered(): array { return $this->registered_connectors; @@ -220,6 +229,7 @@ public function is_registered( string $id ): bool { * * @param string $id The connector identifier. * @return array|null The registered connector data, or null if it is not registered. + * @phpstan-return Connector|null */ public function get_registered( string $id ): ?array { if ( ! $this->is_registered( $id ) ) { From 45571c3b6359e3170c15bdab0c8ed5d3838751e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Mon, 9 Mar 2026 13:18:49 +0100 Subject: [PATCH 09/22] Update src/wp-includes/connectors.php Co-authored-by: Felix Arntz --- src/wp-includes/connectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index fb05b13d044be..6e3b7fbe5566f 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -112,7 +112,7 @@ function wp_unregister_connector( string $id ): ?array { * @param string $id The connector identifier. * @return bool True if the connector is registered, false otherwise. */ -function wp_has_connector( string $id ): bool { +function wp_is_connector_registered( string $id ): bool { $registry = WP_Connector_Registry::get_instance(); if ( null === $registry ) { return false; From b5c0289bb5a03693fb9a5429b5a98114eb013b1c Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Mon, 9 Mar 2026 13:34:41 +0100 Subject: [PATCH 10/22] Connectors: Address review feedback for registry extensibility. - Remove `wp_unregister_connector()` public function; keep `unregister()` on the class. - Rename `wp_has_connector` to `wp_is_connector_registered`. - Decouple initialization from the class: introduce `_wp_connectors_init()` that creates the registry instance, registers built-in connectors directly, and fires `wp_connectors_init` action. - Hook `_wp_connectors_init` on `init` instead of hooking defaults on `wp_connectors_init`, ensuring built-in connectors cannot be unhooked. - Remove `__sleep`/`__wakeup` magic methods from `WP_Connector_Registry`. - Update tests accordingly. Co-Authored-By: Claude Opus 4.6 --- .../class-wp-connector-registry.php | 61 +++---------------- src/wp-includes/connectors.php | 49 +++++++-------- src/wp-includes/default-filters.php | 2 +- .../tests/connectors/wpRegisterConnector.php | 44 +++---------- 4 files changed, 39 insertions(+), 117 deletions(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index d62368f742005..d3c7b41ff298f 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -158,12 +158,8 @@ public function register( string $id, array $args ): ?array { /** * Unregisters a connector. * - * Do not use this method directly. Instead, use the `wp_unregister_connector()` function. - * * @since 7.0.0 * - * @see wp_unregister_connector() - * * @param string $id The connector identifier. * @return array|null The unregistered connector data on success, null on failure. * @@ -205,11 +201,11 @@ public function get_all_registered(): array { /** * Checks if a connector is registered. * - * Do not use this method directly. Instead, use the `wp_has_connector()` function. + * Do not use this method directly. Instead, use the `wp_is_connector_registered()` function. * * @since 7.0.0 * - * @see wp_has_connector() + * @see wp_is_connector_registered() * * @param string $id The connector identifier. * @return bool True if the connector is registered, false otherwise. @@ -245,64 +241,25 @@ public function get_registered( string $id ): ?array { } /** - * Utility method to retrieve the main instance of the registry class. - * - * The instance will be created if it does not exist yet. + * Retrieves the main instance of the registry class. * * @since 7.0.0 * - * @return WP_Connector_Registry|null The main registry instance, or null when `init` action has not fired. + * @return WP_Connector_Registry|null The main registry instance, or null if not yet initialized. */ public static function get_instance(): ?self { - if ( ! did_action( 'init' ) ) { - _doing_it_wrong( - __METHOD__, - sprintf( - /* translators: %s: init action. */ - __( 'Connector registry should not be initialized before the %s action has fired.' ), - 'init' - ), - '7.0.0' - ); - return null; - } - - if ( null === self::$instance ) { - self::$instance = new self(); - - /** - * Fires when preparing connector registry. - * - * Connectors should be registered on this action rather - * than another action to ensure they're only loaded when needed. - * - * @since 7.0.0 - * - * @param WP_Connector_Registry $instance Connector registry object. - */ - do_action( 'wp_connectors_init', self::$instance ); - } - return self::$instance; } /** - * Wakeup magic method. + * Sets the main instance of the registry class. * * @since 7.0.0 - * @throws LogicException If the registry object is unserialized. - */ - public function __wakeup(): void { - throw new LogicException( __CLASS__ . ' should never be unserialized.' ); - } - - /** - * Sleep magic method. + * @access private * - * @since 7.0.0 - * @throws LogicException If the registry object is serialized. + * @param WP_Connector_Registry $registry The registry instance. */ - public function __sleep(): array { - throw new LogicException( __CLASS__ . ' should never be serialized.' ); + public static function set_instance( WP_Connector_Registry $registry ): void { + self::$instance = $registry; } } diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 6e3b7fbe5566f..25709ad08a869 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -17,7 +17,7 @@ * * Example: * - * function my_plugin_register_connectors( WP_Connector_Registry $registry ): void { + * function my_plugin_register_connectors(): void { * wp_register_connector( * 'my_custom_ai', * array( @@ -80,28 +80,6 @@ function wp_register_connector( string $id, array $args ): ?array { return $registry->register( $id, $args ); } -/** - * Unregisters a connector. - * - * Can be called at any time after the connector has been registered. - * - * @since 7.0.0 - * - * @see WP_Connector_Registry::unregister() - * @see wp_register_connector() - * - * @param string $id The connector identifier. - * @return array|null The unregistered connector data on success, null on failure. - */ -function wp_unregister_connector( string $id ): ?array { - $registry = WP_Connector_Registry::get_instance(); - if ( null === $registry ) { - return null; - } - - return $registry->unregister( $id ); -} - /** * Checks if a connector is registered. * @@ -159,12 +137,17 @@ function wp_get_connectors(): array { } /** - * Registers default connectors from Core and the AI Client registry. + * Initializes the connector registry with default connectors and fires the registration action. + * + * Creates the registry instance, registers built-in connectors (which cannot be unhooked), + * and then fires the `wp_connectors_init` action for plugins to register their own connectors. * * @since 7.0.0 * @access private */ -function _wp_register_default_connectors(): void { +function _wp_connectors_init(): void { + $registry = new WP_Connector_Registry(); + WP_Connector_Registry::set_instance( $registry ); // Built-in connectors. $defaults = array( 'anthropic' => array( @@ -252,10 +235,22 @@ function _wp_register_default_connectors(): void { } } - // Register all connectors. + // Register all default connectors directly on the registry. foreach ( $defaults as $id => $args ) { - wp_register_connector( $id, $args ); + $registry->register( $id, $args ); } + + /** + * Fires when the connector registry is ready for plugins to register connectors. + * + * Default connectors have already been registered at this point and cannot be + * unhooked. Use `wp_register_connector()` within this action to add new connectors. + * + * @since 7.0.0 + * + * @param WP_Connector_Registry $registry Connector registry instance. + */ + do_action( 'wp_connectors_init', $registry ); } /** diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 1f7ba06f3102b..ad5ac968877c9 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( 'wp_connectors_init', '_wp_register_default_connectors' ); +add_action( 'init', '_wp_connectors_init' ); // Sitemaps actions. add_action( 'init', 'wp_sitemaps_get_server' ); diff --git a/tests/phpunit/tests/connectors/wpRegisterConnector.php b/tests/phpunit/tests/connectors/wpRegisterConnector.php index 1e05a68a58609..9bb4c39754fa4 100644 --- a/tests/phpunit/tests/connectors/wpRegisterConnector.php +++ b/tests/phpunit/tests/connectors/wpRegisterConnector.php @@ -4,8 +4,7 @@ * * @group connectors * @covers ::wp_register_connector - * @covers ::wp_unregister_connector - * @covers ::wp_has_connector + * @covers ::wp_is_connector_registered * @covers ::wp_get_connector * @covers ::wp_get_connectors */ @@ -77,18 +76,18 @@ function () use ( &$result ) { /** * @ticket 64791 */ - public function test_has_connector_returns_true_for_default() { + public function test_is_connector_registered_returns_true_for_default() { // Default connectors are registered via wp_connectors_init. - $this->assertTrue( wp_has_connector( 'openai' ) ); - $this->assertTrue( wp_has_connector( 'google' ) ); - $this->assertTrue( wp_has_connector( 'anthropic' ) ); + $this->assertTrue( wp_is_connector_registered( 'openai' ) ); + $this->assertTrue( wp_is_connector_registered( 'google' ) ); + $this->assertTrue( wp_is_connector_registered( 'anthropic' ) ); } /** * @ticket 64791 */ - public function test_has_connector_returns_false_for_unregistered() { - $this->assertFalse( wp_has_connector( 'nonexistent_provider' ) ); + public function test_is_connector_registered_returns_false_for_unregistered() { + $this->assertFalse( wp_is_connector_registered( 'nonexistent_provider' ) ); } /** @@ -125,33 +124,4 @@ public function test_get_connectors_returns_all_defaults() { $this->assertArrayHasKey( 'google', $connectors ); $this->assertArrayHasKey( 'anthropic', $connectors ); } - - /** - * @ticket 64791 - */ - public function test_unregister_removes_connector() { - $this->simulate_doing_wp_connectors_init_action( - function () { - wp_register_connector( 'to_remove', self::$default_args ); - } - ); - - $this->assertTrue( wp_has_connector( 'to_remove' ) ); - - $result = wp_unregister_connector( 'to_remove' ); - - $this->assertIsArray( $result ); - $this->assertFalse( wp_has_connector( 'to_remove' ) ); - } - - /** - * @ticket 64791 - */ - public function test_unregister_returns_null_for_nonexistent() { - $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::unregister' ); - - $result = wp_unregister_connector( 'nonexistent' ); - - $this->assertNull( $result ); - } } From 064ef5ecde9f89020758ef6d85984fc8b530c3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Tue, 10 Mar 2026 07:25:51 +0100 Subject: [PATCH 11/22] Apply suggestions from code review Co-authored-by: Weston Ruter --- src/wp-includes/class-wp-connector-registry.php | 6 ++++-- .../phpunit/tests/connectors/wpConnectorRegistry.php | 11 ++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index d3c7b41ff298f..94307e7baaad7 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -61,8 +61,10 @@ final class WP_Connector_Registry { * @type string $method Required. The authentication method: 'api_key' or 'none'. * @type string|null $credentials_url Optional. URL where users can obtain API credentials. * } - * @type array $plugin Optional. Plugin data for install/activate UI. - * @type string $slug The WordPress.org plugin slug. + * @type array $plugin { + * Optional. Plugin data for install/activate UI. + * + * @type string $slug The WordPress.org plugin slug. * } * } * @return array|null The registered connector data on success, null on failure. diff --git a/tests/phpunit/tests/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php index 2d82d3fdf9604..fae481d7c4dbe 100644 --- a/tests/phpunit/tests/connectors/wpConnectorRegistry.php +++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php @@ -5,22 +5,23 @@ * @covers WP_Connector_Registry * * @group connectors + * + * @phpstan-import-type Connector from WP_Connector_Registry */ class Tests_Connectors_WpConnectorRegistry extends WP_UnitTestCase { /** * Connector registry instance. - * - * @var WP_Connector_Registry */ - private $registry = null; + private WP_Connector_Registry $registry; /** * Default valid connector args for testing. * - * @var array + * @var array + * @phpstan-var Connector */ - private static $default_args = array(); + private static array $default_args; /** * Set up each test method. From 0286b11b15334afcc00b79d397badf145b9c2a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Zi=C3=B3=C5=82kowski?= Date: Tue, 10 Mar 2026 07:27:06 +0100 Subject: [PATCH 12/22] Update src/wp-includes/class-wp-connector-registry.php Co-authored-by: Weston Ruter --- src/wp-includes/class-wp-connector-registry.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index 94307e7baaad7..2dd26ce27e5b2 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -22,7 +22,6 @@ final class WP_Connector_Registry { * The singleton instance of the registry. * * @since 7.0.0 - * @var self|null */ private static ?WP_Connector_Registry $instance = null; From 18500249b2f6660718b646c7595f11fce396961a Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 10 Mar 2026 12:08:54 +0100 Subject: [PATCH 13/22] Connectors: Remove wp_register_connector() wrapper in favor of direct registry usage. Plugins now use $registry->register() directly in the wp_connectors_init action callback, which structurally enforces correct registration timing without needing a doing_action() guard. Co-Authored-By: Claude Opus 4.6 --- .../class-wp-connector-registry.php | 4 - src/wp-includes/connectors.php | 89 ++++--------------- .../tests/connectors/wpRegisterConnector.php | 66 +------------- 3 files changed, 19 insertions(+), 140 deletions(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index 2dd26ce27e5b2..80a7ef022bbb9 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -40,12 +40,8 @@ final class WP_Connector_Registry { /** * Registers a new connector. * - * Do not use this method directly. Instead, use the `wp_register_connector()` function. - * * @since 7.0.0 * - * @see wp_register_connector() - * * @param string $id The unique connector identifier. Must contain only lowercase * alphanumeric characters and underscores. * @param array $args { diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 25709ad08a869..7a7a26fc8a514 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -10,76 +10,6 @@ use WordPress\AiClient\AiClient; use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; -/** - * Registers a new connector. - * - * Must be called during the `wp_connectors_init` action. - * - * Example: - * - * function my_plugin_register_connectors(): void { - * wp_register_connector( - * 'my_custom_ai', - * array( - * 'name' => __( 'My Custom AI', 'my-plugin' ), - * 'description' => __( 'Custom AI provider integration.', 'my-plugin' ), - * 'type' => 'ai_provider', - * 'authentication' => array( - * 'method' => 'api_key', - * 'credentials_url' => 'https://example.com/api-keys', - * ), - * ) - * ); - * } - * add_action( 'wp_connectors_init', 'my_plugin_register_connectors' ); - * - * @since 7.0.0 - * - * @see WP_Connector_Registry::register() - * - * @param string $id The unique connector identifier. Must contain only lowercase - * alphanumeric characters and underscores. - * @param array $args { - * An associative array of arguments for the connector. - * - * @type string $name Required. The connector's display name. - * @type string $description Optional. The connector's description. Default empty string. - * @type string $type Required. The connector type. Currently, only 'ai_provider' is supported. - * @type array $authentication { - * Required. Authentication configuration. - * - * @type string $method Required. The authentication method: 'api_key' or 'none'. - * @type string|null $credentials_url Optional. URL where users can obtain API credentials. - * } - * @type array $plugin Optional. Plugin data for install/activate UI. - * @type string $slug The WordPress.org plugin slug. - * } - * } - * @return array|null The registered connector data on success, null on failure. - */ -function wp_register_connector( string $id, array $args ): ?array { - if ( ! doing_action( 'wp_connectors_init' ) ) { - _doing_it_wrong( - __FUNCTION__, - sprintf( - /* translators: 1: wp_connectors_init, 2: string value of the connector ID. */ - __( 'Connectors must be registered on the %1$s action. The connector %2$s was not registered.' ), - 'wp_connectors_init', - '' . esc_html( $id ) . '' - ), - '7.0.0' - ); - return null; - } - - $registry = WP_Connector_Registry::get_instance(); - if ( null === $registry ) { - return null; - } - - return $registry->register( $id, $args ); -} - /** * Checks if a connector is registered. * @@ -244,7 +174,24 @@ function _wp_connectors_init(): void { * Fires when the connector registry is ready for plugins to register connectors. * * Default connectors have already been registered at this point and cannot be - * unhooked. Use `wp_register_connector()` within this action to add new connectors. + * unhooked. Use `$registry->register()` within this action to add new connectors. + * + * Example usage: + * + * add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) { + * $registry->register( + * 'my_custom_ai', + * array( + * 'name' => __( 'My Custom AI', 'my-plugin' ), + * 'description' => __( 'Custom AI provider integration.', 'my-plugin' ), + * 'type' => 'ai_provider', + * 'authentication' => array( + * 'method' => 'api_key', + * 'credentials_url' => 'https://example.com/api-keys', + * ), + * ) + * ); + * } ); * * @since 7.0.0 * diff --git a/tests/phpunit/tests/connectors/wpRegisterConnector.php b/tests/phpunit/tests/connectors/wpRegisterConnector.php index 9bb4c39754fa4..ad55c012d97c7 100644 --- a/tests/phpunit/tests/connectors/wpRegisterConnector.php +++ b/tests/phpunit/tests/connectors/wpRegisterConnector.php @@ -1,78 +1,14 @@ 'Test Connector', - 'description' => 'A test connector.', - 'type' => 'ai_provider', - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://example.com/keys', - ), - ); - } - - /** - * Helper to simulate the wp_connectors_init action for registration. - * - * @param callable $callback The registration callback to run. - */ - private function simulate_doing_wp_connectors_init_action( callable $callback ): void { - global $wp_current_filter; - $wp_current_filter[] = 'wp_connectors_init'; - $callback(); - array_pop( $wp_current_filter ); - } - - /** - * @ticket 64791 - */ - public function test_register_fails_outside_action() { - $this->setExpectedIncorrectUsage( 'wp_register_connector' ); - - $result = wp_register_connector( 'outside_action', self::$default_args ); - - $this->assertNull( $result ); - } - - /** - * @ticket 64791 - */ - public function test_register_succeeds_during_action() { - $result = null; - - $this->simulate_doing_wp_connectors_init_action( - function () use ( &$result ) { - $result = wp_register_connector( 'during_action', self::$default_args ); - } - ); - - $this->assertIsArray( $result ); - $this->assertSame( 'Test Connector', $result['name'] ); - } - /** * @ticket 64791 */ From 7f91d3b0ea83f11084e0dd94de45fcfcdd79314d Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 10 Mar 2026 12:47:20 +0100 Subject: [PATCH 14/22] Connectors: Add timing guard to WP_Connector_Registry::set_instance(). Triggers _doing_it_wrong if set_instance() is called after the init action has completed, preventing late overrides of the registry singleton. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/class-wp-connector-registry.php | 9 +++++++++ tests/phpunit/tests/connectors/wpConnectorRegistry.php | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index 80a7ef022bbb9..ae0ecc7e004b1 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -257,6 +257,15 @@ public static function get_instance(): ?self { * @param WP_Connector_Registry $registry The registry instance. */ public static function set_instance( WP_Connector_Registry $registry ): void { + if ( did_action( 'init' ) && ! doing_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + __( 'The connector registry instance must be set before the init action.' ), + '7.0.0' + ); + return; + } + self::$instance = $registry; } } diff --git a/tests/phpunit/tests/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php index fae481d7c4dbe..a57713f74d4db 100644 --- a/tests/phpunit/tests/connectors/wpConnectorRegistry.php +++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php @@ -329,6 +329,15 @@ public function test_get_instance_returns_registry() { $this->assertInstanceOf( WP_Connector_Registry::class, $instance ); } + /** + * @ticket 64791 + */ + public function test_set_instance_rejects_after_init() { + $this->setExpectedIncorrectUsage( 'WP_Connector_Registry::set_instance' ); + + WP_Connector_Registry::set_instance( new WP_Connector_Registry() ); + } + /** * @ticket 64791 */ From 96e934d49ef48bda3c76ae37138aa8f0523b8056 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 10 Mar 2026 12:57:16 +0100 Subject: [PATCH 15/22] Connectors: Add setting_name to PHPStan Connector type. The register() method conditionally adds setting_name to the authentication array for api_key connectors, but the PHPStan type definition was missing it. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/class-wp-connector-registry.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index ae0ecc7e004b1..48344665d64b2 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -15,7 +15,7 @@ * @since 7.0.0 * @access private * - * @phpstan-type Connector array{ name: string, description: string, type: string, authentication: array{ method: string, credentials_url?: string|null }, plugin?: array{ slug: string } } + * @phpstan-type Connector array{ name: string, description: string, type: string, authentication: array{ method: string, credentials_url?: string|null, setting_name?: string }, plugin?: array{ slug: string } } */ final class WP_Connector_Registry { /** From 5b9603c95d9dbe5e857a60cc3dbe4df77f72a20e Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 10 Mar 2026 12:57:40 +0100 Subject: [PATCH 16/22] Connectors: Fix indentation in catch block. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 7a7a26fc8a514..900a0de50c105 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -461,7 +461,7 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { ); } } catch ( Exception $e ) { - wp_trigger_error( __FUNCTION__, $e->getMessage() ); + wp_trigger_error( __FUNCTION__, $e->getMessage() ); } } add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client', 20 ); From 96e2ff60c4c4d4a0434a3b038fd19fd397599a66 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 10 Mar 2026 13:01:01 +0100 Subject: [PATCH 17/22] Connectors: Format PHPStan Connector type as multi-line. Matches the convention used across the codebase for complex phpstan-type definitions. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/class-wp-connector-registry.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index 48344665d64b2..057f911e03d8c 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -15,7 +15,19 @@ * @since 7.0.0 * @access private * - * @phpstan-type Connector array{ name: string, description: string, type: string, authentication: array{ method: string, credentials_url?: string|null, setting_name?: string }, plugin?: array{ slug: string } } + * @phpstan-type Connector array{ + * name: string, + * description: string, + * type: string, + * authentication: array{ + * method: string, + * credentials_url?: string|null, + * setting_name?: string + * }, + * plugin?: array{ + * slug: string + * } + * } */ final class WP_Connector_Registry { /** From 0362b3525e8d690fd6f229380c63c5e1bcfc4558 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 10 Mar 2026 14:49:53 +0100 Subject: [PATCH 18/22] Connectors: Restrict set_instance() to only work during the init action. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/class-wp-connector-registry.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index 057f911e03d8c..b4f4862dabd52 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -269,10 +269,10 @@ public static function get_instance(): ?self { * @param WP_Connector_Registry $registry The registry instance. */ public static function set_instance( WP_Connector_Registry $registry ): void { - if ( did_action( 'init' ) && ! doing_action( 'init' ) ) { + if ( ! doing_action( 'init' ) ) { _doing_it_wrong( __METHOD__, - __( 'The connector registry instance must be set before the init action.' ), + __( 'The connector registry instance must be set during the init action.' ), '7.0.0' ); return; From 9989675a9efc1e6798b42ee9913d979554da341b Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 11 Mar 2026 10:20:12 +0100 Subject: [PATCH 19/22] Connectors: Add logo_url support to the connector registry. Co-Authored-By: Claude Opus 4.6 --- .../class-wp-connector-registry.php | 6 +++ src/wp-includes/connectors.php | 49 +++++++++++++++++++ .../tests/connectors/wpConnectorRegistry.php | 34 +++++++++++++ 3 files changed, 89 insertions(+) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index b4f4862dabd52..75a6b8ef0c993 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -18,6 +18,7 @@ * @phpstan-type Connector array{ * name: string, * description: string, + * logo_url?: string|null, * type: string, * authentication: array{ * method: string, @@ -61,6 +62,7 @@ final class WP_Connector_Registry { * * @type string $name Required. The connector's display name. * @type string $description Optional. The connector's description. Default empty string. + * @type string|null $logo_url Optional. URL to the connector's logo image. Default null. * @type string $type Required. The connector type. Currently, only 'ai_provider' is supported. * @type array $authentication { * Required. Authentication configuration. @@ -151,6 +153,10 @@ public function register( string $id, array $args ): ?array { ), ); + if ( ! empty( $args['logo_url'] ) && is_string( $args['logo_url'] ) ) { + $connector['logo_url'] = $args['logo_url']; + } + if ( 'api_key' === $args['authentication']['method'] ) { $connector['authentication']['credentials_url'] = $args['authentication']['credentials_url'] ?? null; $connector['authentication']['setting_name'] = "connectors_ai_{$id}_api_key"; diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 900a0de50c105..60f97839dabb6 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -66,6 +66,48 @@ function wp_get_connectors(): array { return $registry->get_all_registered(); } +/** + * Resolves an AI provider logo file path to a URL. + * + * Converts an absolute file path to a plugin URL. The path must reside within + * the plugins or must-use plugins directory. + * + * @since 7.0.0 + * @access private + * + * @param string $path Absolute path to the logo file. + * @return string|null The URL to the logo file, or null if the path is invalid. + */ +function _wp_connectors_resolve_ai_provider_logo_url( string $path ): ?string { + if ( ! $path ) { + return null; + } + + $path = wp_normalize_path( $path ); + + if ( ! file_exists( $path ) ) { + return null; + } + + $mu_plugin_dir = wp_normalize_path( WPMU_PLUGIN_DIR ); + if ( str_starts_with( $path, $mu_plugin_dir . '/' ) ) { + return plugins_url( substr( $path, strlen( $mu_plugin_dir ) ), WPMU_PLUGIN_DIR . '/.' ); + } + + $plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ); + if ( str_starts_with( $path, $plugin_dir . '/' ) ) { + return plugins_url( substr( $path, strlen( $plugin_dir ) ) ); + } + + _doing_it_wrong( + __FUNCTION__, + __( 'Provider logo path must be located within the plugins or must-use plugins directory.' ), + '7.0.0' + ); + + return null; +} + /** * Initializes the connector registry with default connectors and fires the registration action. * @@ -141,6 +183,9 @@ function _wp_connectors_init(): void { $name = $provider_metadata->getName(); $description = $provider_metadata->getDescription(); + $logo_url = $provider_metadata->getLogoPath() + ? _wp_connectors_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() ) + : null; if ( isset( $defaults[ $connector_id ] ) ) { // Override fields with non-empty registry values. @@ -150,6 +195,9 @@ function _wp_connectors_init(): void { if ( $description ) { $defaults[ $connector_id ]['description'] = $description; } + if ( $logo_url ) { + $defaults[ $connector_id ]['logo_url'] = $logo_url; + } // Always update auth method; keep existing credentials_url as fallback. $defaults[ $connector_id ]['authentication']['method'] = $authentication['method']; if ( ! empty( $authentication['credentials_url'] ) ) { @@ -161,6 +209,7 @@ function _wp_connectors_init(): void { 'description' => $description ? $description : '', 'type' => 'ai_provider', 'authentication' => $authentication, + 'logo_url' => $logo_url, ); } } diff --git a/tests/phpunit/tests/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php index a57713f74d4db..161739b7a8ab8 100644 --- a/tests/phpunit/tests/connectors/wpConnectorRegistry.php +++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php @@ -96,6 +96,40 @@ public function test_register_defaults_description_to_empty_string() { $this->assertSame( '', $result['description'] ); } + /** + * @ticket 64791 + */ + public function test_register_includes_logo_url() { + $args = self::$default_args; + $args['logo_url'] = 'https://example.com/logo.png'; + + $result = $this->registry->register( 'with_logo', $args ); + + $this->assertArrayHasKey( 'logo_url', $result ); + $this->assertSame( 'https://example.com/logo.png', $result['logo_url'] ); + } + + /** + * @ticket 64791 + */ + public function test_register_omits_logo_url_when_not_provided() { + $result = $this->registry->register( 'no_logo', self::$default_args ); + + $this->assertArrayNotHasKey( 'logo_url', $result ); + } + + /** + * @ticket 64791 + */ + public function test_register_omits_logo_url_when_empty() { + $args = self::$default_args; + $args['logo_url'] = ''; + + $result = $this->registry->register( 'empty_logo', $args ); + + $this->assertArrayNotHasKey( 'logo_url', $result ); + } + /** * @ticket 64791 */ From 1f8e9a8b4619fd78021811d1b2e92670762bd0ee Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 11 Mar 2026 10:27:50 +0100 Subject: [PATCH 20/22] Tests: Add tests for _wp_connectors_resolve_ai_provider_logo_url(). Co-Authored-By: Claude Opus 4.6 --- .../wpConnectorsResolveAiProviderLogoUrl.php | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/phpunit/tests/connectors/wpConnectorsResolveAiProviderLogoUrl.php diff --git a/tests/phpunit/tests/connectors/wpConnectorsResolveAiProviderLogoUrl.php b/tests/phpunit/tests/connectors/wpConnectorsResolveAiProviderLogoUrl.php new file mode 100644 index 0000000000000..71b1628af3311 --- /dev/null +++ b/tests/phpunit/tests/connectors/wpConnectorsResolveAiProviderLogoUrl.php @@ -0,0 +1,107 @@ +created_files as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); + } + } + foreach ( array_reverse( $this->created_dirs ) as $dir ) { + if ( is_dir( $dir ) ) { + rmdir( $dir ); + } + } + parent::tear_down(); + } + + /** + * Creates a temporary file and tracks it for cleanup. + * + * @param string $path File path. + */ + private function create_file( string $path ): void { + $dir = dirname( $path ); + if ( ! is_dir( $dir ) ) { + wp_mkdir_p( $dir ); + $this->created_dirs[] = $dir; + } + file_put_contents( $path, '' ); + $this->created_files[] = $path; + } + + /** + * @ticket 64791 + */ + public function test_returns_null_when_path_is_empty() { + $this->assertNull( _wp_connectors_resolve_ai_provider_logo_url( '' ) ); + } + + /** + * @ticket 64791 + */ + public function test_resolves_plugin_dir_path_to_url() { + $logo_path = WP_PLUGIN_DIR . '/my-plugin/logo.svg'; + $this->create_file( $logo_path ); + + $result = _wp_connectors_resolve_ai_provider_logo_url( $logo_path ); + + $this->assertSame( site_url( '/wp-content/plugins/my-plugin/logo.svg' ), $result ); + } + + /** + * @ticket 64791 + */ + public function test_resolves_mu_plugin_dir_path_to_url() { + $logo_path = WPMU_PLUGIN_DIR . '/my-mu-plugin/logo.svg'; + $this->create_file( $logo_path ); + + $result = _wp_connectors_resolve_ai_provider_logo_url( $logo_path ); + + $this->assertSame( site_url( '/wp-content/mu-plugins/my-mu-plugin/logo.svg' ), $result ); + } + + /** + * @ticket 64791 + */ + public function test_returns_null_when_file_does_not_exist() { + $this->assertNull( + _wp_connectors_resolve_ai_provider_logo_url( WP_PLUGIN_DIR . '/nonexistent/logo.svg' ) + ); + } + + /** + * @ticket 64791 + * @expectedIncorrectUsage _wp_connectors_resolve_ai_provider_logo_url + */ + public function test_returns_null_and_triggers_doing_it_wrong_for_path_outside_plugin_dirs() { + $tmp_file = tempnam( sys_get_temp_dir(), 'logo_' ); + file_put_contents( $tmp_file, '' ); + $this->created_files[] = $tmp_file; + + $this->assertNull( _wp_connectors_resolve_ai_provider_logo_url( $tmp_file ) ); + } +} From 408d5c4b0b3444156ad78e35b439e26845c334a6 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 11 Mar 2026 11:06:11 +0100 Subject: [PATCH 21/22] Connectors: Add method_exists() check for getLogoPath(). The getLogoPath() method may not be available in all versions of the PHP AI client. This guards against a fatal error when the method is missing. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 60f97839dabb6..ba5ef6d712f2d 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -183,7 +183,7 @@ function _wp_connectors_init(): void { $name = $provider_metadata->getName(); $description = $provider_metadata->getDescription(); - $logo_url = $provider_metadata->getLogoPath() + $logo_url = method_exists( $provider_metadata, 'getLogoPath' ) && $provider_metadata->getLogoPath() ? _wp_connectors_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() ) : null; From 5abc91e2ec3e39e85c1d907641c5c1abaa1d315b Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 11 Mar 2026 16:48:35 +0100 Subject: [PATCH 22/22] Connectors: Remove method_exists() check for getLogoPath(). The check is no longer needed after bringing upstream changes from trunk. Co-Authored-By: Claude Opus 4.6 --- src/wp-includes/connectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index ba5ef6d712f2d..60f97839dabb6 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -183,7 +183,7 @@ function _wp_connectors_init(): void { $name = $provider_metadata->getName(); $description = $provider_metadata->getDescription(); - $logo_url = method_exists( $provider_metadata, 'getLogoPath' ) && $provider_metadata->getLogoPath() + $logo_url = $provider_metadata->getLogoPath() ? _wp_connectors_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() ) : null;