From 0d00beee54b1dc16f9cadb62e733c579e2036099 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:18:15 +0000 Subject: [PATCH 1/5] Initial plan From 5940d573b9e64defcc5fdb96629ba2b7478afb34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:28:36 +0000 Subject: [PATCH 2/5] feat: use WP 7.0 connectors option naming and masking for credentials Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/credentials.feature | 10 ++--- src/Credentials_Command.php | 81 +++++++++++++++++++++--------------- 2 files changed, 52 insertions(+), 39 deletions(-) diff --git a/features/credentials.feature b/features/credentials.feature index 0603aee..cd0916a 100644 --- a/features/credentials.feature +++ b/features/credentials.feature @@ -33,7 +33,7 @@ Feature: Manage AI provider credentials When I run `wp ai credentials list --format=json` Then STDOUT should be JSON containing: """ - [{"provider":"openai","api_key":"sk-*********6789"}] + [{"provider":"openai","api_key":"••••••••••••6789"}] """ @require-wp-7.0 @@ -51,7 +51,7 @@ Feature: Manage AI provider credentials """ And STDOUT should contain: """ - "api_key":"sk-**********-123" + "api_key":"••••••••••••••-123" """ @require-wp-7.0 @@ -100,8 +100,8 @@ Feature: Manage AI provider credentials And I run `wp ai credentials list` Then STDOUT should be a table containing rows: | provider | api_key | - | openai | sk-*****i123 | - | anthropic | sk-*******-456 | + | openai | ••••••••i123 | + | anthropic | ••••••••••-456 | @require-wp-7.0 Scenario: Update existing credentials @@ -120,5 +120,5 @@ Feature: Manage AI provider credentials When I run `wp ai credentials get openai --format=json` Then STDOUT should contain: """ - "api_key":"new****-456" + "api_key":"•••••••-456" """ diff --git a/src/Credentials_Command.php b/src/Credentials_Command.php index 48e687f..47bd986 100644 --- a/src/Credentials_Command.php +++ b/src/Credentials_Command.php @@ -25,9 +25,14 @@ class Credentials_Command extends WP_CLI_Command { /** - * The option name where credentials are stored. + * The option name prefix where credentials are stored. */ - const OPTION_NAME = 'wp_ai_client_provider_credentials'; + const OPTION_PREFIX = 'connectors_ai_'; + + /** + * The option name suffix where credentials are stored. + */ + const OPTION_SUFFIX = '_api_key'; /** * Lists all stored AI provider credentials. @@ -164,11 +169,11 @@ public function set( $args, $assoc_args ) { list( $provider ) = $args; $api_key = $assoc_args['api-key']; - $credentials = $this->get_all_credentials(); - - $credentials[ $provider ] = $api_key; + $option_name = self::OPTION_PREFIX . $provider . self::OPTION_SUFFIX; - $this->save_all_credentials( $credentials ); + // Remove any sanitize callback to bypass provider-side validation (e.g., live API checks). + remove_all_filters( "sanitize_option_{$option_name}" ); + update_option( $option_name, $api_key, false ); WP_CLI::success( sprintf( 'Credentials for provider "%s" have been saved.', $provider ) ); } @@ -202,8 +207,7 @@ public function delete( $args, $assoc_args ) { WP_CLI::error( sprintf( 'Credentials for provider "%s" not found.', $provider ) ); } - unset( $credentials[ $provider ] ); - $this->save_all_credentials( $credentials ); + delete_option( self::OPTION_PREFIX . $provider . self::OPTION_SUFFIX ); WP_CLI::success( sprintf( 'Credentials for provider "%s" have been deleted.', $provider ) ); } @@ -214,51 +218,60 @@ public function delete( $args, $assoc_args ) { * @return array */ private function get_all_credentials() { - $credentials = get_option( self::OPTION_NAME, array() ); + global $wpdb; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like( self::OPTION_PREFIX ) . '%' . $wpdb->esc_like( self::OPTION_SUFFIX ) + ), + ARRAY_A + ); - if ( ! is_array( $credentials ) ) { + if ( ! is_array( $results ) ) { return array(); } - /** - * @var array $credentials - */ + $credentials = array(); + $prefix_length = strlen( self::OPTION_PREFIX ); + $suffix_length = strlen( self::OPTION_SUFFIX ); - return $credentials; - } - - /** - * Saves all credentials to the database. - * - * @param array $credentials The credentials to save. - * @return bool - */ - private function save_all_credentials( $credentials ) { - if ( empty( $credentials ) ) { - return delete_option( self::OPTION_NAME ); + foreach ( $results as $row ) { + if ( ! is_array( $row ) ) { + continue; + } + /** @var array $row */ + $option_name = $row['option_name']; + if ( strlen( $option_name ) <= $prefix_length + $suffix_length ) { + continue; + } + $provider = substr( $option_name, $prefix_length, -$suffix_length ); + $credentials[ $provider ] = $row['option_value']; } - return update_option( self::OPTION_NAME, $credentials, false ); + ksort( $credentials ); + + return $credentials; } /** * Masks an API key for display purposes. * + * Uses the same logic as WordPress core's `_wp_connectors_mask_api_key()`. + * * @param string $api_key The API key to mask. * @return string */ private function mask_api_key( $api_key ) { - if ( empty( $api_key ) ) { - return ''; + if ( function_exists( '_wp_connectors_mask_api_key' ) ) { + return _wp_connectors_mask_api_key( $api_key ); } - $length = strlen( $api_key ); - - if ( $length <= 8 ) { - return str_repeat( '*', $length ); + if ( strlen( $api_key ) <= 4 ) { + return $api_key; } - // Show first 3 and last 4 characters - return substr( $api_key, 0, 3 ) . str_repeat( '*', min( 10, $length - 7 ) ) . substr( $api_key, -4 ); + return str_repeat( "\u{2022}", min( strlen( $api_key ) - 4, 16 ) ) . substr( $api_key, -4 ); } } From b82b63a05cbc1203066a451a3e2a70c5e157c99a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:50:32 +0000 Subject: [PATCH 3/5] refactor: replace DB query with get_option() loop; remove mask_api_key() Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- src/Credentials_Command.php | 72 ++++++++++++------------------------- 1 file changed, 23 insertions(+), 49 deletions(-) diff --git a/src/Credentials_Command.php b/src/Credentials_Command.php index 47bd986..c8dd506 100644 --- a/src/Credentials_Command.php +++ b/src/Credentials_Command.php @@ -79,7 +79,7 @@ public function list_( $args, $assoc_args ) { foreach ( $credentials as $provider => $api_key ) { $items[] = array( 'provider' => $provider, - 'api_key' => $this->mask_api_key( $api_key ?? '' ), + 'api_key' => $api_key, ); } @@ -119,15 +119,17 @@ public function list_( $args, $assoc_args ) { public function get( $args, $assoc_args ) { list( $provider ) = $args; - $credentials = $this->get_all_credentials(); + $option_name = self::OPTION_PREFIX . $provider . self::OPTION_SUFFIX; + $raw_key = get_option( $option_name, '' ); + $api_key = is_string( $raw_key ) ? $raw_key : ''; - if ( ! isset( $credentials[ $provider ] ) ) { + if ( '' === $api_key ) { WP_CLI::error( sprintf( 'Credentials for provider "%s" not found.', $provider ) ); } $data = array( 'provider' => $provider, - 'api_key' => $this->mask_api_key( $credentials[ $provider ] ?? '' ), + 'api_key' => $api_key, ); $format = $assoc_args['format'] ?? 'json'; @@ -201,13 +203,15 @@ public function set( $args, $assoc_args ) { public function delete( $args, $assoc_args ) { list( $provider ) = $args; - $credentials = $this->get_all_credentials(); + $option_name = self::OPTION_PREFIX . $provider . self::OPTION_SUFFIX; + $raw_key = get_option( $option_name, '' ); + $api_key = is_string( $raw_key ) ? $raw_key : ''; - if ( ! isset( $credentials[ $provider ] ) ) { + if ( '' === $api_key ) { WP_CLI::error( sprintf( 'Credentials for provider "%s" not found.', $provider ) ); } - delete_option( self::OPTION_PREFIX . $provider . self::OPTION_SUFFIX ); + delete_option( $option_name ); WP_CLI::success( sprintf( 'Credentials for provider "%s" have been deleted.', $provider ) ); } @@ -218,60 +222,30 @@ public function delete( $args, $assoc_args ) { * @return array */ private function get_all_credentials() { - global $wpdb; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $results = $wpdb->get_results( - $wpdb->prepare( - "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s", - $wpdb->esc_like( self::OPTION_PREFIX ) . '%' . $wpdb->esc_like( self::OPTION_SUFFIX ) - ), - ARRAY_A - ); - - if ( ! is_array( $results ) ) { + if ( ! function_exists( '_wp_connectors_get_connector_settings' ) ) { return array(); } - $credentials = array(); - $prefix_length = strlen( self::OPTION_PREFIX ); - $suffix_length = strlen( self::OPTION_SUFFIX ); + $credentials = array(); - foreach ( $results as $row ) { - if ( ! is_array( $row ) ) { + foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { + if ( ! isset( $connector_data['authentication'] ) ) { continue; } - /** @var array $row */ - $option_name = $row['option_name']; - if ( strlen( $option_name ) <= $prefix_length + $suffix_length ) { + $auth = $connector_data['authentication']; + if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } - $provider = substr( $option_name, $prefix_length, -$suffix_length ); - $credentials[ $provider ] = $row['option_value']; + + $raw = get_option( $auth['setting_name'], '' ); + $value = is_string( $raw ) ? $raw : ''; + if ( '' !== $value ) { + $credentials[ $connector_id ] = $value; + } } ksort( $credentials ); return $credentials; } - - /** - * Masks an API key for display purposes. - * - * Uses the same logic as WordPress core's `_wp_connectors_mask_api_key()`. - * - * @param string $api_key The API key to mask. - * @return string - */ - private function mask_api_key( $api_key ) { - if ( function_exists( '_wp_connectors_mask_api_key' ) ) { - return _wp_connectors_mask_api_key( $api_key ); - } - - if ( strlen( $api_key ) <= 4 ) { - return $api_key; - } - - return str_repeat( "\u{2022}", min( strlen( $api_key ) - 4, 16 ) ) . substr( $api_key, -4 ); - } } From 6dd44868c92b08fe73d3d7dae63bbefc38385462 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 5 Mar 2026 10:25:34 +0100 Subject: [PATCH 4/5] Update a couple of tests --- features/credentials.feature | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/credentials.feature b/features/credentials.feature index cd0916a..644ad93 100644 --- a/features/credentials.feature +++ b/features/credentials.feature @@ -51,7 +51,7 @@ Feature: Manage AI provider credentials """ And STDOUT should contain: """ - "api_key":"••••••••••••••-123" + "api_key":"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022-123" """ @require-wp-7.0 @@ -120,5 +120,5 @@ Feature: Manage AI provider credentials When I run `wp ai credentials get openai --format=json` Then STDOUT should contain: """ - "api_key":"•••••••-456" + "api_key":"\u2022\u2022\u2022\u2022\u2022\u2022\u2022-456" """ From aee30132be2650a6961f04c04e7b40080777949f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:04:10 +0000 Subject: [PATCH 5/5] refactor: route all credential commands through connector registry Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/credentials.feature | 2 +- src/Credentials_Command.php | 70 +++++++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/features/credentials.feature b/features/credentials.feature index 644ad93..15febbc 100644 --- a/features/credentials.feature +++ b/features/credentials.feature @@ -80,7 +80,7 @@ Feature: Manage AI provider credentials When I try `wp ai credentials get nonexistent` Then STDERR should contain: """ - Error: Credentials for provider "nonexistent" not found. + Error: Provider "nonexistent" is not a supported AI connector. """ And the return code should be 1 diff --git a/src/Credentials_Command.php b/src/Credentials_Command.php index c8dd506..b444f9d 100644 --- a/src/Credentials_Command.php +++ b/src/Credentials_Command.php @@ -24,16 +24,6 @@ */ class Credentials_Command extends WP_CLI_Command { - /** - * The option name prefix where credentials are stored. - */ - const OPTION_PREFIX = 'connectors_ai_'; - - /** - * The option name suffix where credentials are stored. - */ - const OPTION_SUFFIX = '_api_key'; - /** * Lists all stored AI provider credentials. * @@ -57,7 +47,7 @@ class Credentials_Command extends WP_CLI_Command { * +----------+----------+ * | provider | api_key | * +----------+----------+ - * | openai | sk-***** | + * | openai | ••••••• | * +----------+----------+ * * @subcommand list @@ -108,7 +98,7 @@ public function list_( $args, $assoc_args ) { * * # Get OpenAI credentials * $ wp ai credentials get openai - * {"provider":"openai","api_key":"sk-*****"} + * {"provider":"openai","api_key":"••••••••••••6789"} * * @when after_wp_load * @@ -119,7 +109,7 @@ public function list_( $args, $assoc_args ) { public function get( $args, $assoc_args ) { list( $provider ) = $args; - $option_name = self::OPTION_PREFIX . $provider . self::OPTION_SUFFIX; + $option_name = $this->get_connector_setting_name( $provider ); $raw_key = get_option( $option_name, '' ); $api_key = is_string( $raw_key ) ? $raw_key : ''; @@ -171,7 +161,7 @@ public function set( $args, $assoc_args ) { list( $provider ) = $args; $api_key = $assoc_args['api-key']; - $option_name = self::OPTION_PREFIX . $provider . self::OPTION_SUFFIX; + $option_name = $this->get_connector_setting_name( $provider ); // Remove any sanitize callback to bypass provider-side validation (e.g., live API checks). remove_all_filters( "sanitize_option_{$option_name}" ); @@ -203,7 +193,7 @@ public function set( $args, $assoc_args ) { public function delete( $args, $assoc_args ) { list( $provider ) = $args; - $option_name = self::OPTION_PREFIX . $provider . self::OPTION_SUFFIX; + $option_name = $this->get_connector_setting_name( $provider ); $raw_key = get_option( $option_name, '' ); $api_key = is_string( $raw_key ) ? $raw_key : ''; @@ -216,6 +206,46 @@ public function delete( $args, $assoc_args ) { WP_CLI::success( sprintf( 'Credentials for provider "%s" have been deleted.', $provider ) ); } + /** + * Gets the option name for a provider's API key from the connector registry. + * + * @param string $provider The connector/provider ID. + * @return string The option name. + */ + private function get_connector_setting_name( string $provider ): string { + if ( ! function_exists( '_wp_connectors_get_connector_settings' ) ) { + WP_CLI::error( 'Requires WordPress 7.0 or greater.' ); + } + + $settings = _wp_connectors_get_connector_settings(); + + if ( ! isset( $settings[ $provider ] ) ) { + WP_CLI::error( sprintf( 'Provider "%s" is not a supported AI connector.', $provider ) ); + } + + $setting_name = $this->get_api_key_setting_name( $settings[ $provider ]['authentication'] ?? [] ); + + if ( null === $setting_name ) { + WP_CLI::error( sprintf( 'Provider "%s" does not support API key authentication.', $provider ) ); + } + + return $setting_name; + } + + /** + * Returns the option/setting name if the given authentication config is an API key type, or null otherwise. + * + * @param mixed $auth Authentication config from the connector registry. + * @return string|null + */ + private function get_api_key_setting_name( $auth ): ?string { + if ( ! is_array( $auth ) || ! isset( $auth['method'] ) || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + return null; + } + + return (string) $auth['setting_name']; + } + /** * Gets all credentials from the database. * @@ -229,15 +259,13 @@ private function get_all_credentials() { $credentials = array(); foreach ( _wp_connectors_get_connector_settings() as $connector_id => $connector_data ) { - if ( ! isset( $connector_data['authentication'] ) ) { - continue; - } - $auth = $connector_data['authentication']; - if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + $setting_name = $this->get_api_key_setting_name( $connector_data['authentication'] ?? [] ); + + if ( null === $setting_name ) { continue; } - $raw = get_option( $auth['setting_name'], '' ); + $raw = get_option( $setting_name, '' ); $value = is_string( $raw ) ? $raw : ''; if ( '' !== $value ) { $credentials[ $connector_id ] = $value;