Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
2112fa8
Initial route registration with extensibility script loading example
jorgefilipecosta Feb 23, 2026
6604d0d
bootstrap UI
jorgefilipecosta Feb 23, 2026
eea90e2
base components version
jorgefilipecosta Feb 23, 2026
2045880
Improve UI
jorgefilipecosta Feb 23, 2026
2621e63
rename to connectors
jorgefilipecosta Feb 23, 2026
07694cb
expandable draft state
jorgefilipecosta Feb 23, 2026
fb4ba8d
tmp connector api via global
jorgefilipecosta Feb 23, 2026
8214ea7
tmp connector global api
jorgefilipecosta Feb 23, 2026
1ead5a0
script modules approaach
jorgefilipecosta Feb 23, 2026
2e536aa
script module pattern working
jorgefilipecosta Feb 23, 2026
e4dc487
working connection api with an example
jorgefilipecosta Feb 23, 2026
2392c0f
remove unrequired prop
jorgefilipecosta Feb 23, 2026
9e8398b
extract defaults to separate file
jorgefilipecosta Feb 23, 2026
6a543af
fix gemini logo
jorgefilipecosta Feb 23, 2026
1812aa7
fix open ai logo
jorgefilipecosta Feb 23, 2026
56e05da
properly activate and install gemini
jorgefilipecosta Feb 23, 2026
160e249
Update the package lock file
gziolo Feb 24, 2026
9f6d184
Fix the reported PHPCS issue
gziolo Feb 24, 2026
df48889
Position Connectors menu item after General in Settings
gziolo Feb 24, 2026
22c72e2
Add plugin directory search link to Connectors page
gziolo Feb 24, 2026
84bd4ee
fix gemini slug
jorgefilipecosta Feb 24, 2026
b630db4
initial gemini connection
jorgefilipecosta Feb 24, 2026
cc18956
working gemini connection with debug code
jorgefilipecosta Feb 24, 2026
9ada092
proper loading action
jorgefilipecosta Feb 24, 2026
069a71c
made a generic defaukt-connectors.php
jorgefilipecosta Feb 24, 2026
d42370a
implement for other providers
jorgefilipecosta Feb 24, 2026
6a5806b
UI polishing
jorgefilipecosta Feb 24, 2026
aaa082d
fix gemini logo
jorgefilipecosta Feb 24, 2026
a3a491e
UI enhacements
jorgefilipecosta Feb 24, 2026
b3426bc
Ui improvements take two
jorgefilipecosta Feb 24, 2026
0b32e53
UI enhancment
jorgefilipecosta Feb 24, 2026
92ad29f
lint fixes
jorgefilipecosta Feb 24, 2026
58d2b46
mask api keys
jorgefilipecosta Feb 24, 2026
e6ec507
Fix max number of chats on mask
jorgefilipecosta Feb 24, 2026
40ca346
Add client side key validation
jorgefilipecosta Feb 24, 2026
eacfe66
improve debug code
jorgefilipecosta Feb 24, 2026
727d144
add save time key valdiation
jorgefilipecosta Feb 24, 2026
d4c0d47
add get level validation
jorgefilipecosta Feb 24, 2026
733dceb
fix read time validation
jorgefilipecosta Feb 24, 2026
ab5de8d
Fix PHPCS errors in connectors PHP files
gziolo Feb 25, 2026
045d624
Add E2E tests for Connectors page and BEM class names for provider cards
gziolo Feb 25, 2026
d4285b4
Rename "connections" to "connectors" for consistency
gziolo Feb 25, 2026
0349c34
fix await
jorgefilipecosta Feb 25, 2026
9ba27a5
don't include mechanism if AI client is not available
jorgefilipecosta Feb 25, 2026
b94264c
avoid anonymous functions
jorgefilipecosta Feb 25, 2026
d219ba3
mark functions as private
jorgefilipecosta Feb 25, 2026
6102089
Added shortcircuit on option to avoid validation keys on every get re…
jorgefilipecosta Feb 25, 2026
3bec2be
Converservtive validation
jorgefilipecosta Feb 25, 2026
9985d04
avoid loading example extender
jorgefilipecosta Feb 25, 2026
023afbd
UI improvements
jorgefilipecosta Feb 25, 2026
dd111ba
make functions private
jorgefilipecosta Feb 25, 2026
82890c7
fix dimensions
jorgefilipecosta Feb 25, 2026
19757e7
Extract logos to a separate file
jorgefilipecosta Feb 25, 2026
8cdc3c5
fix key already exists after dispatch
jorgefilipecosta Feb 25, 2026
579e63a
remove connector example code
jorgefilipecosta Feb 25, 2026
b72cea2
add loading check
jorgefilipecosta Feb 25, 2026
43ce449
make api's private and experimental
jorgefilipecosta Feb 25, 2026
93ae861
remove client validaiton
jorgefilipecosta Feb 25, 2026
7c867b8
Improve validation message
jorgefilipecosta Feb 25, 2026
1bf97fb
remove debug code
jorgefilipecosta Feb 25, 2026
5d9b28f
fix lint issues
jorgefilipecosta Feb 25, 2026
897aaab
add types
jorgefilipecosta Feb 25, 2026
007c9d4
Update lib/experimental/connectors/default-connectors.php
jorgefilipecosta Feb 25, 2026
67c84de
Update lib/experimental/connectors/default-connectors.php
jorgefilipecosta Feb 25, 2026
001c038
Update lib/experimental/connectors/default-connectors.php
jorgefilipecosta Feb 25, 2026
ad8ca56
Update lib/experimental/connectors/default-connectors.php
jorgefilipecosta Feb 25, 2026
f66b513
Update lib/experimental/connectors/default-connectors.php
jorgefilipecosta Feb 25, 2026
1e17207
Update lib/experimental/connectors/default-connectors.php
jorgefilipecosta Feb 25, 2026
ed83d19
Update lib/experimental/connectors/default-connectors.php
jorgefilipecosta Feb 25, 2026
c9b2ead
Update lib/experimental/connectors/default-connectors.php
jorgefilipecosta Feb 25, 2026
f369988
Update lib/experimental/connectors/default-connectors.php
jorgefilipecosta Feb 25, 2026
c4db60a
Update lib/experimental/connectors/load.php
jorgefilipecosta Feb 25, 2026
10d0205
Update lib/experimental/connectors/default-connectors.php
jorgefilipecosta Feb 25, 2026
e07d6a5
Update lib/experimental/connectors/default-connectors.php
jorgefilipecosta Feb 25, 2026
98388d5
remove connectors array duplication
jorgefilipecosta Feb 25, 2026
a16e665
remove pre_update_option_ filter rely on santizie
jorgefilipecosta Feb 25, 2026
10f0dd4
fix plugin install url
jorgefilipecosta Feb 25, 2026
7fd1ca7
design feedback
jorgefilipecosta Feb 25, 2026
5e47da7
lint fix
jorgefilipecosta Feb 25, 2026
550567d
fix tsconfig
jorgefilipecosta Feb 25, 2026
a6670b9
fix end to end tests
jorgefilipecosta Feb 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
323 changes: 323 additions & 0 deletions lib/experimental/connectors/default-connectors.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
<?php
/**
* Default connectors backend logic.
*
* @package gutenberg
*/

/**
* Masks an API key, showing only the last 4 characters.
*
* @access private
*
* @param string $key The API key to mask.
* @return string The masked key, e.g. "••••••••••••fj39".
*/
function _gutenberg_mask_api_key( string $key ): string {
if ( strlen( $key ) <= 4 ) {
return $key;
}
return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 );
}

/**
* Checks whether an API key is valid for a given provider.
*
* @access private
*
* @param string $key The API key to check.
* @param string $provider_id The WP AI client provider ID.
* @return bool|null True if valid, false if invalid, null if unable to determine.
*/
function _gutenberg_is_api_key_valid( string $key, string $provider_id ): ?bool {
try {
$registry = \WordPress\AiClient\AiClient::defaultRegistry();

if ( ! $registry->hasProvider( $provider_id ) ) {
return null;
}

$registry->setProviderRequestAuthentication(
$provider_id,
new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $key )
);

return $registry->isProviderConfigured( $provider_id );
} catch ( \Error $e ) {
return null;
}
}

/**
* Sets the API key authentication for a provider on the WP AI Client registry.
*
* @access private
*
* @param string $key The API key.
* @param string $provider_id The WP AI client provider ID.
*/
function _gutenberg_set_provider_api_key( string $key, string $provider_id ): void {
try {
$registry = \WordPress\AiClient\AiClient::defaultRegistry();

if ( ! $registry->hasProvider( $provider_id ) ) {
return;
}

$registry->setProviderRequestAuthentication(
$provider_id,
new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $key )
);
} catch ( \Error $e ) {
// WP AI Client not available.
}
}

/**
* Retrieves the real (unmasked) value of a connector API key.
*
* Temporarily removes the masking filter, reads the option, then re-adds it.
*
* @access private
*
* @param string $option_name The option name for the API key.
* @param callable $mask_callback The mask filter function.
* @return string The real API key value.
*/
function _gutenberg_get_real_api_key( string $option_name, callable $mask_callback ): string {
remove_filter( "option_{$option_name}", $mask_callback );
$value = get_option( $option_name, '' );
add_filter( "option_{$option_name}", $mask_callback );
Comment on lines +88 to +90
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Masking is nice, but should we also encrypt out API keys before we store them in them the database? Seems like a bigger problem than before when giving AI access to your database is one of the bigger practical use cases for agentic site work.

Also confirming - add_filter( 'option_{$option_name}', static fn () => MY_PRIVATE_ENV_CONST, 9 ); is the only way to programmatically bypass db storage for enterprise and other security or deployment minded folks who care about these things, and that too might break in 7.1?

return $value;
}

// --- Gemini (Google) ---

/**
* Masks the Gemini API key on read.
*
* @access private
*
* @param string $value The raw option value.
* @return string Masked key or empty string.
*/
function _gutenberg_mask_gemini_api_key( string $value ): string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are all private anyway, why even bother? I say we ditch the boilerplate and just call _gutenberg_{mask|sanitize}_api_key() directly unles/until we have one that deviates.
I hope we can agree that long term we don't want any of this in core itself anyway over the actual providers, so imo no reason to ship any of this boilerplate.

if ( '' === $value ) {
return $value;
}
return _gutenberg_mask_api_key( $value );
}

/**
* Sanitizes and validates the Gemini API key before saving.
*
* @access private
*
* @param string $value The new value.
* @return string The sanitized value, or empty string if the key is not valid.
*/
function _gutenberg_sanitize_gemini_api_key( string $value ): string {
$value = sanitize_text_field( $value );
if ( '' === $value ) {
return $value;
}
$valid = _gutenberg_is_api_key_valid( $value, 'google' );
return true === $valid ? $value : '';
}

// --- OpenAI ---

/**
* Masks the OpenAI API key on read.
*
* @access private
*
* @param string $value The raw option value.
* @return string Masked key or empty string.
*/
function _gutenberg_mask_openai_api_key( string $value ): string {
if ( '' === $value ) {
return $value;
}
return _gutenberg_mask_api_key( $value );
}

/**
* Sanitizes and validates the OpenAI API key before saving.
*
* @access private
*
* @param string $value The new value.
* @return string The sanitized value, or empty string if the key is not valid.
*/
function _gutenberg_sanitize_openai_api_key( string $value ): string {
$value = sanitize_text_field( $value );
if ( '' === $value ) {
return $value;
}
$valid = _gutenberg_is_api_key_valid( $value, 'openai' );
return true === $valid ? $value : '';
}

// --- Anthropic ---

/**
* Masks the Anthropic API key on read.
*
* @access private
*
* @param string $value The raw option value.
* @return string Masked key or empty string.
*/
function _gutenberg_mask_anthropic_api_key( string $value ): string {
if ( '' === $value ) {
return $value;
}
return _gutenberg_mask_api_key( $value );
}

/**
* Sanitizes and validates the Anthropic API key before saving.
*
* @access private
*
* @param string $value The new value.
* @return string The sanitized value, or empty string if the key is not valid.
*/
function _gutenberg_sanitize_anthropic_api_key( string $value ): string {
$value = sanitize_text_field( $value );
if ( '' === $value ) {
return $value;
}
$valid = _gutenberg_is_api_key_valid( $value, 'anthropic' );
return true === $valid ? $value : '';
}

// --- Connector definitions ---

/**
* Gets the provider connectors.
*
* @access private
*
* @return array<string, array{ provider: string, mask: callable, sanitize: callable }> Connectors.
*/
function _gutenberg_get_connectors(): array {
return array(
'connectors_gemini_api_key' => array(
'provider' => 'google',
'mask' => '_gutenberg_mask_gemini_api_key',
'sanitize' => '_gutenberg_sanitize_gemini_api_key',
),
'connectors_openai_api_key' => array(
'provider' => 'openai',
'mask' => '_gutenberg_mask_openai_api_key',
'sanitize' => '_gutenberg_sanitize_openai_api_key',
),
'connectors_anthropic_api_key' => array(
'provider' => 'anthropic',
'mask' => '_gutenberg_mask_anthropic_api_key',
'sanitize' => '_gutenberg_sanitize_anthropic_api_key',
),
);
}

// --- REST API filtering ---

/**
* Validates connector API keys in the REST response when explicitly requested.
*
* Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include
* connector fields via `_fields`. For each requested connector field, reads
* the real (unmasked) key, validates it against the provider, and replaces
* the response value with 'invalid_key' if validation fails.
*
* @access private
*
* @param WP_REST_Response $response The response object.
* @param WP_REST_Server $server The server instance.
* @param WP_REST_Request $request The request object.
* @return WP_REST_Response The potentially modified response.
*/
function _gutenberg_validate_connector_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response {
if ( '/wp/v2/settings' !== $request->get_route() ) {
return $response;
}

$fields = $request->get_param( '_fields' );
if ( ! $fields ) {
return $response;
}

$requested = array_map( 'trim', explode( ',', $fields ) );
$data = $response->get_data();
$connectors = _gutenberg_get_connectors();

foreach ( $connectors as $option_name => $config ) {
if ( ! in_array( $option_name, $requested, true ) ) {
continue;
}
$real_key = _gutenberg_get_real_api_key( $option_name, $config['mask'] );
if ( '' === $real_key ) {
continue;
}
if ( true !== _gutenberg_is_api_key_valid( $real_key, $config['provider'] ) ) {
$data[ $option_name ] = 'invalid_key';
}
}

$response->set_data( $data );
return $response;
}
add_filter( 'rest_post_dispatch', '_gutenberg_validate_connector_keys_in_rest', 10, 3 );

// --- Registration ---

/**
* Registers the default connector settings, mask filters, and validation filters.
*
* @access private
*/
function _gutenberg_register_default_connector_settings(): void {
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
return;
}

$connectors = _gutenberg_get_connectors();

foreach ( $connectors as $option_name => $config ) {
register_setting(
'connectors',
$option_name,
array(
'type' => 'string',
'default' => '',
'show_in_rest' => true,
'sanitize_callback' => $config['sanitize'],
)
);
add_filter( "option_{$option_name}", $config['mask'] );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is at least one hole I'm aware of for this method of masking the API key: the key is still visible when you go to /wp-admin/options.php (the hidden options screen):

Image

(My actual key has been redacted in this screenshot.)

Copy link
Member

@gziolo gziolo Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds like these API key options should always be masked by default, but the filter is instead temporarily removed when passing to the AI provider class when auth is being configured.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, we need a proper security audit here, because we try to mirror how Claude and OpenAI manage API keys. However, we also noticed that Google allows displaying the stored API key for Gemini, so maybe it's fine to skip that masking.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nuance to consider: just because you can use an API Key on one site (or are authorized to connect a new one) doesn't mean you can use that key off site in your own tooling.

(related https://github.com/WordPress/gutenberg/pull/75833/files#r2855648259 )

}
}
add_action( 'init', '_gutenberg_register_default_connector_settings' );

/**
* Passes the default connector API keys to the WP AI client.
*
* @access private
*/
function _gutenberg_pass_default_connector_keys_to_ai_client(): void {
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
return;
}

$connectors = _gutenberg_get_connectors();

foreach ( $connectors as $option_name => $config ) {
$api_key = _gutenberg_get_real_api_key( $option_name, $config['mask'] );
if ( ! empty( $api_key ) ) {
_gutenberg_set_provider_api_key( $api_key, $config['provider'] );
}
}
}
add_action( 'init', '_gutenberg_pass_default_connector_keys_to_ai_client' );
27 changes: 27 additions & 0 deletions lib/experimental/connectors/load.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php
/**
* Bootstraps the Connectors page in wp-admin.
*
* @package gutenberg
*/

add_action( 'admin_menu', '_gutenberg_connectors_add_settings_menu_item' );

/**
* Registers the Connectors menu item under Settings.
*
* @access private
*/
function _gutenberg_connectors_add_settings_menu_item(): void {
add_submenu_page(
'options-general.php',
__( 'Connectors', 'gutenberg' ),
__( 'Connectors', 'gutenberg' ),
'manage_options',
'connectors-wp-admin',
'gutenberg_connectors_wp_admin_render_page',
1
);
}

require __DIR__ . '/default-connectors.php';
3 changes: 3 additions & 0 deletions lib/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/experimental/pages/site-editor.php';
require __DIR__ . '/experimental/extensible-site-editor.php';
require __DIR__ . '/experimental/fonts/load.php';
if ( class_exists( '\WordPress\AiClient\AiClient' ) ) {
require __DIR__ . '/experimental/connectors/load.php';
}

if ( gutenberg_is_experiment_enabled( 'gutenberg-workflow-palette' ) ) {
require __DIR__ . '/experimental/workflow-palette.php';
Expand Down
Loading
Loading