From aa8f9f20ef6ba7aca09e7baae29616371ed56b5f Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Mon, 4 May 2026 15:14:58 +0900 Subject: [PATCH 1/7] Connectors: Defer `plugin.is_active` check to connector retrieval. Previously, `WP_Connector_Registry::register()` silently filled in `__return_true` when no `is_active` callback was provided, which made it impossible for consumers to distinguish "no callback supplied" from "callback always returns true." Defer the check to call sites instead, so the stored connector data reflects what was actually registered. Update `_wp_register_default_connector_settings()` and `_wp_connectors_get_connector_script_module_data()` to treat a missing callback as active, matching the documented `Defaults to __return_true` semantics. See #65020. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wp-includes/class-wp-connector-registry.php | 4 ---- src/wp-includes/connectors.php | 9 +++++++-- tests/phpunit/tests/connectors/wpConnectorRegistry.php | 7 +++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index fbf35ad73e21d..2b9ce772e7b99 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -270,10 +270,6 @@ public function register( string $id, array $args ): ?array { } } - if ( ! isset( $connector['plugin']['is_active'] ) ) { - $connector['plugin']['is_active'] = '__return_true'; - } - $this->registered_connectors[ $id ] = $connector; return $connector; } diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index b5f2354ddd93a..82a1158710756 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -682,8 +682,13 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { ); if ( ! empty( $connector_data['plugin']['file'] ) ) { - $file = $connector_data['plugin']['file']; - $is_activated = (bool) call_user_func( $connector_data['plugin']['is_active'] ); + $file = $connector_data['plugin']['file']; + if ( ! isset( $connector_data['plugin']['is_active'] ) ) { + // Assume plugin has registered own connector and is therefore active. + $is_activated = true; + } else { + $is_activated = (bool) call_user_func( $connector_data['plugin']['is_active'] ); + } $is_installed = $is_activated || file_exists( wp_normalize_path( WP_PLUGIN_DIR . '/' . $file ) ); $connector_out['plugin'] = array( diff --git a/tests/phpunit/tests/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php index 47e12eb7fd6fd..6020ba38a92ef 100644 --- a/tests/phpunit/tests/connectors/wpConnectorRegistry.php +++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php @@ -339,15 +339,14 @@ public function test_register_rejects_non_callable_plugin_is_active() { /** * @ticket 65020 */ - public function test_register_defaults_plugin_is_active_to_return_true() { + public function test_register_omits_plugin_is_active_when_not_provided() { $args = self::$default_args; $args['plugin'] = array( 'file' => 'my-plugin/my-plugin.php' ); $result = $this->registry->register( 'default-callback', $args ); $this->assertIsArray( $result ); - $this->assertArrayHasKey( 'is_active', $result['plugin'] ); - $this->assertSame( '__return_true', $result['plugin']['is_active'] ); + $this->assertArrayNotHasKey( 'is_active', $result['plugin'] ); } /** @@ -358,7 +357,7 @@ public function test_register_defaults_plugin_when_not_provided() { $this->assertArrayHasKey( 'plugin', $result ); $this->assertArrayNotHasKey( 'file', $result['plugin'] ); - $this->assertSame( '__return_true', $result['plugin']['is_active'] ); + $this->assertArrayNotHasKey( 'is_active', $result['plugin'] ); } /** From 9cb5f08dd57984b34853dfaf9a27848b467d6aa7 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Tue, 5 May 2026 18:50:51 +0900 Subject: [PATCH 2/7] Connectors: Harden plugin install detection and document `is_active` default. Replace the raw `file_exists()` install probe in `_wp_connectors_get_connector_script_module_data()` with `validate_plugin()`, which adds path traversal protection and confirms the file is a recognised plugin rather than just any file at that path. Restores the `require_once` for `wp-admin/includes/plugin.php` that the function depends on. Also updates the `is_active` docblock in `WP_Connector_Registry::register()` to spell out that an omitted callback paired with a `file` is treated as active on the assumption that the plugin must be loaded in order to register itself, so callers do not mistake the omission for a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wp-includes/class-wp-connector-registry.php | 4 +++- src/wp-includes/connectors.php | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index 2b9ce772e7b99..a1e2b8e16f5d6 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -115,7 +115,9 @@ final class WP_Connector_Registry { * 'hello.php'). * @type callable $is_active Optional callback to determine whether the plugin * is active. Receives no arguments and must return bool. - * Defaults to `__return_true`. + * When omitted and `file` is provided, the connector is + * treated as active on the assumption that the plugin + * must be loaded in order to register itself. * } * } * @return array|null The registered connector data on success, null on failure. diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 82a1158710756..5303ed43061e1 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -651,6 +651,10 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { function _wp_connectors_get_connector_script_module_data( array $data ): array { $registry = AiClient::defaultRegistry(); + if ( ! function_exists( 'validate_plugin' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + $connectors = array(); foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; @@ -689,7 +693,7 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { } else { $is_activated = (bool) call_user_func( $connector_data['plugin']['is_active'] ); } - $is_installed = $is_activated || file_exists( wp_normalize_path( WP_PLUGIN_DIR . '/' . $file ) ); + $is_installed = $is_activated || 0 === validate_plugin( $file ); $connector_out['plugin'] = array( 'file' => $file, From bcf570c2f65f18d9ac9cb3acde21b05719c063c4 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 6 May 2026 11:11:26 +0900 Subject: [PATCH 3/7] Connectors: Normalize `plugin.is_active` default at registration. Move the missing-callback fallback from `_wp_connectors_get_connector_script_module_data()` into `WP_Connector_Registry::register()`, where the registered connector now always has `plugin.is_active` set to `__return_true` when omitted. This lets every consumer call the callback unconditionally instead of repeating an `isset()` guard, and tightens the PHPStan shape so `is_active` is no longer optional on the stored array. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wp-includes/class-wp-connector-registry.php | 10 ++++++---- src/wp-includes/connectors.php | 7 +------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index a1e2b8e16f5d6..4fd451b2b0996 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -41,7 +41,7 @@ * }, * plugin?: array{ * file: non-empty-string, - * is_active?: callable(): bool + * is_active: callable(): bool * } * } */ @@ -115,9 +115,7 @@ final class WP_Connector_Registry { * 'hello.php'). * @type callable $is_active Optional callback to determine whether the plugin * is active. Receives no arguments and must return bool. - * When omitted and `file` is provided, the connector is - * treated as active on the assumption that the plugin - * must be loaded in order to register itself. + * Defaults to `__return_true`. * } * } * @return array|null The registered connector data on success, null on failure. @@ -272,6 +270,10 @@ public function register( string $id, array $args ): ?array { } } + if ( ! isset( $connector['plugin']['is_active'] ) ) { + $connector['plugin']['is_active'] = '__return_true'; + } + $this->registered_connectors[ $id ] = $connector; return $connector; } diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 5303ed43061e1..530c558358c88 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -687,12 +687,7 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { if ( ! empty( $connector_data['plugin']['file'] ) ) { $file = $connector_data['plugin']['file']; - if ( ! isset( $connector_data['plugin']['is_active'] ) ) { - // Assume plugin has registered own connector and is therefore active. - $is_activated = true; - } else { - $is_activated = (bool) call_user_func( $connector_data['plugin']['is_active'] ); - } + $is_activated = (bool) call_user_func( $connector_data['plugin']['is_active'] ); $is_installed = $is_activated || 0 === validate_plugin( $file ); $connector_out['plugin'] = array( From a80e2ad09ed2f8789a0caa5d5d274bf3d5e4362e Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 6 May 2026 11:13:25 +0900 Subject: [PATCH 4/7] Connectors: Restore tests for `plugin.is_active` default at registration. Follow-up to the previous commit, which moved the `__return_true` fallback back into `WP_Connector_Registry::register()`. Restore the test assertions so they once again expect `plugin.is_active` to be set to `__return_true` when a callback is omitted, matching the registered shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/phpunit/tests/connectors/wpConnectorRegistry.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/connectors/wpConnectorRegistry.php b/tests/phpunit/tests/connectors/wpConnectorRegistry.php index 6020ba38a92ef..47e12eb7fd6fd 100644 --- a/tests/phpunit/tests/connectors/wpConnectorRegistry.php +++ b/tests/phpunit/tests/connectors/wpConnectorRegistry.php @@ -339,14 +339,15 @@ public function test_register_rejects_non_callable_plugin_is_active() { /** * @ticket 65020 */ - public function test_register_omits_plugin_is_active_when_not_provided() { + public function test_register_defaults_plugin_is_active_to_return_true() { $args = self::$default_args; $args['plugin'] = array( 'file' => 'my-plugin/my-plugin.php' ); $result = $this->registry->register( 'default-callback', $args ); $this->assertIsArray( $result ); - $this->assertArrayNotHasKey( 'is_active', $result['plugin'] ); + $this->assertArrayHasKey( 'is_active', $result['plugin'] ); + $this->assertSame( '__return_true', $result['plugin']['is_active'] ); } /** @@ -357,7 +358,7 @@ public function test_register_defaults_plugin_when_not_provided() { $this->assertArrayHasKey( 'plugin', $result ); $this->assertArrayNotHasKey( 'file', $result['plugin'] ); - $this->assertArrayNotHasKey( 'is_active', $result['plugin'] ); + $this->assertSame( '__return_true', $result['plugin']['is_active'] ); } /** From 097a005eccffd9553588b01913a2ecd9ff7d2972 Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 6 May 2026 11:16:54 +0900 Subject: [PATCH 5/7] Connectors: Correct PHPStan shape for the stored `plugin` array. `WP_Connector_Registry::register()` always initializes `$connector['plugin']` as an array and always defaults `is_active` to `__return_true`, while `file` is only set when supplied. Reflect that in the type: `plugin` is required on the stored connector and `file` is the optional key, not the other way around. Co-Authored-By: Claude Opus 4.7 (1M context) --- 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 4fd451b2b0996..62d007d11e838 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -39,8 +39,8 @@ * constant_name?: non-empty-string, * env_var_name?: non-empty-string * }, - * plugin?: array{ - * file: non-empty-string, + * plugin: array{ + * file?: non-empty-string, * is_active: callable(): bool * } * } From 2307bf39a28ebf0bb9da7cf3af076466c533bb2b Mon Sep 17 00:00:00 2001 From: Aki Hamano Date: Wed, 6 May 2026 18:10:10 +0900 Subject: [PATCH 6/7] Connectors: Align `$file` assignment with surrounding statements. Why: The variable was flagged by `Generic.Formatting.MultipleStatementAlignment` because the equals sign was not aligned with the adjacent `$is_activated` and `$is_installed` assignments. Co-Authored-By: Claude Opus 4.7 (1M context) --- 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 530c558358c88..3ab6c1f445788 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -686,7 +686,7 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { ); if ( ! empty( $connector_data['plugin']['file'] ) ) { - $file = $connector_data['plugin']['file']; + $file = $connector_data['plugin']['file']; $is_activated = (bool) call_user_func( $connector_data['plugin']['is_active'] ); $is_installed = $is_activated || 0 === validate_plugin( $file ); From e15d15557e5545eef78f3bcd6ff005ead2f457d6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 6 May 2026 14:11:21 -0700 Subject: [PATCH 7/7] Fix type issues identified by PHPStan rule level 10 --- .../class-wp-connector-registry.php | 20 +++++- src/wp-includes/connectors.php | 65 +++++++++---------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/src/wp-includes/class-wp-connector-registry.php b/src/wp-includes/class-wp-connector-registry.php index 62d007d11e838..77e9a0df87d13 100644 --- a/src/wp-includes/class-wp-connector-registry.php +++ b/src/wp-includes/class-wp-connector-registry.php @@ -29,7 +29,7 @@ * * @phpstan-type Connector array{ * name: non-empty-string, - * description: non-empty-string, + * description: string, * logo_url?: non-empty-string, * type: non-empty-string, * authentication: array{ @@ -120,7 +120,23 @@ final class WP_Connector_Registry { * } * @return array|null The registered connector data on success, null on failure. * - * @phpstan-param Connector $args + * @phpstan-param array{ + * name: non-empty-string, + * description?: string, + * logo_url?: non-empty-string, + * type: non-empty-string, + * authentication: array{ + * method: 'api_key'|'none', + * credentials_url?: non-empty-string, + * setting_name?: non-empty-string, + * constant_name?: non-empty-string, + * env_var_name?: non-empty-string + * }, + * plugin?: array{ + * file?: non-empty-string, + * is_active?: callable(): bool + * } + * } $args * @phpstan-return Connector|null */ public function register( string $id, array $args ): ?array { diff --git a/src/wp-includes/connectors.php b/src/wp-includes/connectors.php index 3ab6c1f445788..148b57b7586d0 100644 --- a/src/wp-includes/connectors.php +++ b/src/wp-includes/connectors.php @@ -64,7 +64,7 @@ function wp_is_connector_registered( string $id ): bool { * } * @phpstan-return ?array{ * name: non-empty-string, - * description: non-empty-string, + * description: string, * logo_url?: non-empty-string, * type: non-empty-string, * authentication: array{ @@ -75,7 +75,8 @@ function wp_is_connector_registered( string $id ): bool { * env_var_name?: non-empty-string * }, * plugin?: array{ - * file: non-empty-string + * file?: non-empty-string, + * is_active: callable(): bool, * } * } */ @@ -126,7 +127,7 @@ function wp_get_connector( string $id ): ?array { * } * @phpstan-return array */ @@ -160,7 +162,7 @@ function wp_get_connectors(): array { * @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. + * @return non-empty-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 ) { @@ -175,12 +177,14 @@ function _wp_connectors_resolve_ai_provider_logo_url( string $path ): ?string { $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 . '/.' ); + $logo_url = plugins_url( substr( $path, strlen( $mu_plugin_dir ) ), WPMU_PLUGIN_DIR . '/.' ); + return $logo_url ? $logo_url : null; } $plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ); if ( str_starts_with( $path, $plugin_dir . '/' ) ) { - return plugins_url( substr( $path, strlen( $plugin_dir ) ) ); + $logo_url = plugins_url( substr( $path, strlen( $plugin_dir ) ) ); + return $logo_url ? $logo_url : null; } _doing_it_wrong( @@ -295,7 +299,7 @@ function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $re // Registry values (from provider plugins) take precedence over hardcoded fallbacks. $ai_registry = AiClient::defaultRegistry(); - foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) { + foreach ( array_filter( $ai_registry->getRegisteredProviderIds() ) as $connector_id ) { $provider_class_name = $ai_registry->getProviderClassName( $connector_id ); $provider_metadata = $provider_class_name::metadata(); @@ -305,9 +309,11 @@ function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $re if ( $is_api_key ) { $credentials_url = $provider_metadata->getCredentialsUrl(); $authentication = array( - 'method' => 'api_key', - 'credentials_url' => $credentials_url ? $credentials_url : null, + 'method' => 'api_key', ); + if ( $credentials_url ) { + $authentication['credentials_url'] = $credentials_url; + } } else { $authentication = array( 'method' => 'none' ); } @@ -340,8 +346,10 @@ function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $re 'description' => $description ? $description : '', 'type' => 'ai_provider', 'authentication' => $authentication, - 'logo_url' => $logo_url, ); + if ( $logo_url ) { + $defaults[ $connector_id ]['logo_url'] = $logo_url; + } } } @@ -350,33 +358,22 @@ function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $re if ( 'api_key' === $args['authentication']['method'] ) { $sanitized_id = str_replace( '-', '_', $id ); - if ( ! isset( $args['authentication']['setting_name'] ) ) { - $args['authentication']['setting_name'] = "connectors_ai_{$sanitized_id}_api_key"; - } + $args['authentication']['setting_name'] = "connectors_ai_{$sanitized_id}_api_key"; // All AI providers use the {CONSTANT_CASE_ID}_API_KEY naming convention. - if ( ! isset( $args['authentication']['constant_name'] ) || ! isset( $args['authentication']['env_var_name'] ) ) { - $constant_case_key = strtoupper( preg_replace( '/([a-z])([A-Z])/', '$1_$2', $sanitized_id ) ) . '_API_KEY'; - - if ( ! isset( $args['authentication']['constant_name'] ) ) { - $args['authentication']['constant_name'] = $constant_case_key; - } + $constant_case_key = strtoupper( (string) preg_replace( '/([a-z])([A-Z])/', '$1_$2', $sanitized_id ) ) . '_API_KEY'; - if ( ! isset( $args['authentication']['env_var_name'] ) ) { - $args['authentication']['env_var_name'] = $constant_case_key; - } - } + $args['authentication']['constant_name'] = $constant_case_key; + $args['authentication']['env_var_name'] = $constant_case_key; } - if ( ! isset( $args['plugin']['is_active'] ) ) { - $args['plugin']['is_active'] = static function () use ( $ai_registry, $id ): bool { - try { - return $ai_registry->hasProvider( $id ); - } catch ( Exception $e ) { - return false; - } - }; - } + $args['plugin']['is_active'] = static function () use ( $ai_registry, $id ): bool { + try { + return $ai_registry->hasProvider( $id ); + } catch ( Exception $e ) { + return false; + } + }; $registry->register( $id, $args ); } @@ -624,7 +621,7 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { } $api_key = get_option( $auth['setting_name'], '' ); - if ( '' === $api_key ) { + if ( ! is_string( $api_key ) || '' === $api_key ) { continue; }