Skip to content

Abilities API: Add a core/settings-get ability - #12141

Open
jorgefilipecosta wants to merge 53 commits into
WordPress:trunkfrom
jorgefilipecosta:add/core-settings-ability
Open

jorgefilipecosta wants to merge 53 commits into
WordPress:trunkfrom
jorgefilipecosta:add/core-settings-ability

Conversation

@jorgefilipecosta

@jorgefilipecosta jorgefilipecosta commented Jun 9, 2026 •

Copy link
Copy Markdown
Member

What?

Port of WordPress/ai#691 (renamed in WordPress/ai#1087), part of WordPress/ai#40. Adds a read-only core/settings-get ability.

  • register_setting() gets a show_in_abilities argument, false by default. true reuses the setting's REST name and schema, so blogname is exposed as title, as in /wp/v2/settings. An array with name and schema keys is used instead. register_initial_settings() flags 19 settings, such as blogname, posts_per_page, and default_comment_status.
  • core/settings-get returns the exposed settings as a flat map of name to value, optionally filtered by group, fields, or both. It requires manage_options.
  • Each value is validated against its schema before and after sanitizing. A value that fails is left out, so one bad stored value does not fail the whole call.
  • The initial settings are also registered on wp_abilities_api_init, so the ability finds them on cron, WP-CLI, and before rest_api_init.
  • The code lives in the private WP_Abilities_Settings class, which the planned core/settings-update ability (Add a core/settings-update ability ai#764) can share.

Testing

Verify unit tests are passing:

npm run test:php -- --group abilities-api

Manual test steps: #12141 (comment)

AI disclosure: issues found via AI-assisted code review; the fixes and this description were drafted with AI assistance (Claude Code) and reviewed by me.

Trac ticket: https://core.trac.wordpress.org/ticket/64605

@github-actions

github-actions Bot commented Jun 9, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props jorgefilipecosta, gziolo, justlevine.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@jorgefilipecosta jorgefilipecosta changed the title Abilities API: Add a core/settings ability [in progress] Abilities API: Add a core/settings ability Jun 9, 2026
@justlevine

Copy link
Copy Markdown

@jorgefilipecosta can you link this to the trac ticket please? I don't have edit perms in this repo.
https://core.trac.wordpress.org/ticket/64605

@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-settings-ability branch from 073df67 to c948c7a Compare June 12, 2026 15:01
@gziolo
gziolo self-requested a review June 15, 2026 12:31
@jorgefilipecosta

Copy link
Copy Markdown
Member Author

@jorgefilipecosta can you link this to the trac ticket please? I don't have edit perms in this repo. core.trac.wordpress.org/ticket/64605

Nice catch the ticket mention was added.

@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-settings-ability branch from c948c7a to a28c976 Compare June 15, 2026 19:32
@jorgefilipecosta jorgefilipecosta changed the title [in progress] Abilities API: Add a core/settings ability Abilities API: Add a core/settings ability Jun 16, 2026
@gziolo

gziolo commented Jun 17, 2026

Copy link
Copy Markdown
Member

Let's run development, review, and testing through WordPress/ai#691, then sync all agreed refinements here.

@jorgefilipecosta
jorgefilipecosta force-pushed the add/core-settings-ability branch 3 times, most recently from 24bee86 to 6419a95 Compare June 23, 2026 15:23
@jorgefilipecosta jorgefilipecosta changed the title Abilities API: Add a core/settings ability Abilities API: Add a core/read-settings ability Jul 1, 2026

@gziolo gziolo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I left my feedback.

Two questions regarding core/get-site-info:

  • It covers some similar settings but is scoped to the site. Should it also respect the show_in_abilities check wherever applicable?
  • Should it get the default value in the schema aligned to (object) array() as here?

Comment thread src/wp-includes/abilities/class-wp-settings-abilities.php Outdated
Comment thread src/wp-includes/abilities/class-wp-abilities-settings.php Outdated
*/
private function register_get_settings(): void {
// Compute once; execute_get_settings() reuses this exact structure.
$this->exposed_settings = $this->get_exposed_settings();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I noticed that Core settings get registered on the rest_api_init hook:

add_action( 'rest_api_init', 'register_initial_settings', 10 );

It's worth double-checking whether there won't be a race condition with wp_abilities_api_init.

The long-term question is whether get_exposed_settings() could be computed lazily inside the execute/schema callbacks instead of being cached at registration? That would remove the ordering coupling and match the sibling's live‑compute model.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch on the ordering. wp_abilities_api_init fires lazily and isn't ordered relative to rest_api_init, so register() now calls register_initial_settings() before computing the snapshot whenever rest_api_init hasn't fired (or is mid-fire before priority 10). Added the guard in ae862f0 and documented the timing in 59e1819; re-registering later on rest_api_init is harmless.

On computing it lazily: I kept the snapshot cached at registration on purpose. The input schema, output schema, and execute callback all have to derive from the exact same set of exposed settings, so computing it once avoids walking get_registered_settings() three times per request and any risk of the schema and the output drifting apart. With the ordering handled explicitly there's no coupling left to remove, so I dropped the unreachable recompute branch in 13f4d31.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Following your later suggestion, core settings are now registered on wp_abilities_api_init, so the ability does not handle this anymore 👍

Comment thread tests/phpunit/tests/abilities-api/wpRegisterCoreSettingsAbility.php Outdated
@jorgefilipecosta

Copy link
Copy Markdown
Member Author

I left my feedback.

Two questions regarding core/get-site-info:

  • It covers some similar settings but is scoped to the site. Should it also respect the show_in_abilities check wherever applicable?
  • Should it get the default value in the schema aligned to (object) array() as here?

Hi @gziolo I think the answer is yes to both, but I would prefer to do that in a separate PR to avoid this one being too huge.

@jorgefilipecosta

Copy link
Copy Markdown
Member Author

All the reviews comments were applied this is ready for another look.

@gziolo

gziolo commented Jul 13, 2026 •

Copy link
Copy Markdown
Member

One bad setting value fails the whole core/read-settings call

A behavior question to seal with a test.

execute() validates the full output against the schema, so one stored value that does not match its schema makes the entire call return a WP_Error. The client then reads none of the settings.

This is reachable. default_ping_status and default_comment_status use an open|closed enum, and their stored value can drift outside it (direct update_option(), imports, old data). sanitize_option() only maps '0' and '' to 'closed', so a value like 'not-a-valid-status' sticks. admin_email (format: email) is a second, lower chance trigger.

Commit 714d164 handled this by validating and sanitizing each value against its schema (like WP_REST_Settings_Controller::prepare_value()) and dropping only the bad one. The sync commit a6167bf swapped it back to cast_value() to match the AI plugin, which brought the problem back.

A test that pins it down:

public function test_core_read_settings_drops_values_that_fail_their_schema(): void {
	$this->become_admin();

	// sanitize_option() only coerces '0' and '' to 'closed', so this out-of-enum value sticks.
	update_option( 'default_ping_status', 'not-a-valid-status' );

	$result = wp_get_ability( 'core/read-settings' )->execute( array() );

	$this->assertNotWPError( $result, 'One bad value must not fail the whole ability.' );
	$this->assertArrayHasKey( 'blogname', $result );               // others still returned
	$this->assertArrayNotHasKey( 'default_ping_status', $result ); // only the bad one dropped
}

On this branch it fails:

Ability "core/read-settings" has invalid output. Reason: output[default_ping_status] is not one of open and closed.

With the 714d164 approach restored, it passes.

Decision: what should happen for a value that does not match its schema?

  • A (my preference): drop that value and return the rest. The test above encodes this.
  • B: keep the cast and accept the whole call failing. If so, let's add a test that documents this on purpose.

The plugin class mirrors the core class, so the same choice should land there too. Happy to help with the plugin side.


Side question (independent from the above): the sibling abilities core/get-site-info, core/get-user-info, and core/get-environment-info still use 'default' => array() for their input schema, while core/read-settings uses 'default' => (object) array(). So an empty type: object default serializes as [] for them and {} here. Worth aligning them to (object) array() too? It does not depend on the decision above, so maybe it is cleaner as its own small commit.

@gziolo

gziolo commented Jul 13, 2026 •

Copy link
Copy Markdown
Member

Consider wiring register_initial_settings on wp_abilities_api_init too

The ability builds its schema from the registered settings at registration time, so those settings must exist when wp_abilities_api_init fires. Today register_initial_settings is only hooked to rest_api_init, and that action fires lazily inside rest_get_server() when the REST server is first built. On cron, WP-CLI, or any direct ability call that never builds the REST server, the settings are not registered yet. That is why register() calls register_initial_settings() itself with the did_action/doing_action guard.

It works, but it makes the ability responsible for core's bootstrap ordering. A cleaner shape could be to treat the settings as a shared dependency and wire them into both subsystems that use them:

add_action( 'rest_api_init',         'register_initial_settings', 10 );
add_action( 'wp_abilities_api_init', 'register_initial_settings', 1 ); // before core abilities (priority 10)

To make this safe to run from more than one hook, register_initial_settings() could get a small internal check that returns early when the core settings are already registered. register_setting() appends to $new_allowed_options on every call, so without such a guard a second run duplicates those entries and re-fires the register_setting action. The check should look at the actual registration state (for example whether a known core setting like blogname is present) rather than a static flag, so it stays correct when the registry is reset, as the tests do.

With that in place the settings register once and correctly in both cases, REST and Abilities, and the ability no longer needs its own did_action/doing_action logic.

What do you think? If it sounds good I am happy to help wire it up.

// Compute once; execute_get_settings() reuses this exact structure.
$this->exposed_settings = $this->get_exposed_settings();

$settings = $this->exposed_settings;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If there are no settings registered, then there is no need to register the ability as it won't return anything anyway.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The ability is now not registered when no setting is exposed 👍

*
* @access private
*/
final class WP_Settings_Abilities {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nitpick, here and in other open PRs, it might make more sense to follow naming conventions from other places and use WP_Abilities_ prefix and follow with class-wp-abilities- as file name. This would better mirror how it would look when using namespaces: WordPress/Abilities/Settings.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Renamed to WP_Abilities_Settings 👍

@gziolo

gziolo commented Jul 13, 2026

Copy link
Copy Markdown
Member

Thanks @jorgefilipecosta! Nice work on this. The port is faithful to the plugin, the option.php change is clean and well-documented, and CI is green. We are nearly there.

The remaining points are the ones I raised above. The main one is the bad-value handling, since it needs an actual fix here and in the plugin so they stay in sync. The rest are shared conventions and small polish: the WP_Abilities_ class naming (across the abilities PRs) and the empty-object input default (across the sibling abilities). Once the bad-value case is settled, this is good to go from my side.

Restore the default that 656b453 changed to `array()`. An object is serialized as `{}` wherever the schema is read, and it keeps the class identical to the AI plugin's, which supports WordPress 7.0, where the abilities endpoints send an empty array default as `[]`.
@gziolo

gziolo commented Oct 7, 2026

Copy link
Copy Markdown
Member

jorgefilipecosta#91 – we should consider offering a solid strategy around settings registration that is predictable not only for WP core registered settings but also for those coming from plugins. I propsed we enforce that enabling show_in_abilities warns when setting gets registered after init making it clear when to register the setting.

jorgefilipecosta and others added 4 commits October 7, 2026 10:20
WordPress stores `false` as `''`, which `rest_is_boolean()` rejects, so
`core/settings-get` left out every boolean setting whose value is false,
including a Settings API checkbox saved unchecked. The settings endpoint
answers `null` for it.

Read `''` as `false` for a boolean setting before validating it.
`WP_REST_Settings_Controller::get_registered_options()` runs
`rest_default_additional_properties_to_false()` on every setting schema,
so objects reject properties they do not declare. `core/settings-get`
skipped that step, so it returned a stored object with an undeclared
property whole, where the settings endpoint answers `null`.

Close the schema in `value_schema()`, so the ability reads values, and
describes them in its output schema, as the endpoint does.
The input schema accepts an object, but `execute_get_settings()` replaced
anything other than an array with an empty one, so a PHP caller that
passed `(object) array( 'group' => 'reading' )` got every setting back.

Normalize the input with `rest_sanitize_object()`.
… exposure.

Set the `public` meta flag, as the other core abilities do. This
carries over WordPress/ai#1122. In core, `public` also enables
`show_in_rest`.

When `show_in_abilities` is `true`, a setting now uses the same name
and schema as in `show_in_rest`. An array is still used as is. All core
settings use `true`, so they share their names and schemas with the
REST API settings endpoint. For example, `blogname` is exposed as
`title` and `WPLANG` as `language`, and the email and enum schemas are
no longer repeated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gziolo

gziolo commented Oct 7, 2026

Copy link
Copy Markdown
Member

I pushed 749c2eb with two changes.

1. The ability is now public.

core/settings-get now sets 'public' => true, like the other core abilities. This carries over WordPress/ai#1122. In core, public also enables show_in_rest, so the old flag is no longer needed.

2. show_in_abilities => true reuses the REST API setup.

When show_in_abilities is true, the setting now uses the same name and schema as in show_in_rest. When it is an array, it is used as is.

Because of this, all core settings now use only 'show_in_abilities' => true. We no longer repeat the email format or the open|closed enum. The names now match /wp/v2/settings:

Option Ability name
blogname title
blogdescription description
siteurl url
admin_email email
timezone_string timezone
WPLANG language
wp_page_for_privacy_policy page_for_privacy_policy

The other settings already had the same name in both APIs.

One small side effect: siteurl now also gets the uri format from REST. If the stored value is not a valid URI, the ability leaves it out, like the REST API does.

New tests check that the names match the REST API, and that an array in show_in_abilities replaces the REST arguments.

Two follow-ups:

  • The PR description and the manual test comment still use the old names, like blogname.
  • The AI plugin still uses the old names, so it needs a similar change.

gziolo and others added 2 commits October 7, 2026 12:15
…fault.

Match the other core abilities. wp_prepare_json_schema_for_client()
already sends an empty object default as `{}`, for both the REST API
and the AI client. With an array, the execute callback also gets an
array when no input is given.

Add tests for how wp_prepare_json_schema_for_client() handles an
empty default on the root object schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a separate PR.

They cover existing behavior of the helper, so they do not belong to this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`set_up_before_class()` swaps the core abilities registration hooks and
clears the `rest_api_init` count, and only `tear_down_after_class()` put
them back. The first test of a run snapshots the hooks, and every test
then restores that snapshot, so whenever this class ran first, its
changes leaked into every later test. Put the hooks and the count back
right after registering the abilities.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 7, 2026
Port the core change in WordPress/wordpress-develop#12141 (749c2eb430).
When show_in_abilities is true, a setting now takes the name and schema
from show_in_rest; an array is still used as is. Show_In_Abilities flags
every core setting with true, as option.php does, so core/settings-get,
its deprecated core/read-settings alias, and core/settings-update use
the settings endpoint's names: blogname is title, blogdescription is
description, siteurl is url, admin_email is email, timezone_string is
timezone, WPLANG is language, and wp_page_for_privacy_policy is
page_for_privacy_policy. The email format and the comment status enums
now come from show_in_rest, and siteurl gains its uri format, which the
abilities client accepts since WordPress 7.1.

Port core's tests for the REST names and the inherited exposure, use
the new names in the other tests and the e2e specs, and drop the
Show_In_Abilities test for a curated array value, since none is left.

Co-authored-by: Grzegorz Ziolkowski <grzegorz@gziolo.pl>
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 7, 2026
Port the core change in WordPress/wordpress-develop#12141 (e7e6a2340e),
which matches the other core abilities. Since WordPress 7.1,
wp_prepare_json_schema_for_client() sends an empty object default as {}
on the abilities endpoint and through the AI client, and the execute
callback gets an array when no input is given. Drop the Plugin: comment
that kept the object for WordPress 7.0, so the class matches core again.

Co-authored-by: Grzegorz Ziolkowski <grzegorz@gziolo.pl>
The input schema validates `fields` with `rest_is_array()`, which also
accepts a comma-separated string, but `execute_get_settings()` replaced
anything other than an array with an empty one, so a PHP caller that
passed `'title,posts_per_page'` got every setting back. REST requests
were not affected, because the run endpoint converts the input to the
schema types first.

Normalize the list with `rest_sanitize_array()`.
…w_in_rest`.

An array replaces the `show_in_rest` name and schema entirely, so a
setting that only sets a `name` for abilities also drops its REST
schema, such as an email format. Say so in the `register_setting()` and
`get_registered_settings()` docs.
`show_in_abilities => true` reuses the `show_in_rest` name and schema,
so guard the opt-in rule next to it: a setting shown in the REST API
without `show_in_abilities` is not exposed.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 7, 2026
Port the core change in WordPress/wordpress-develop#12141 (30742fc248).
The input schema validates `fields` with rest_is_array(), which also
accepts a comma-separated string, but execute_get_settings() replaced
anything other than an array with an empty one, so a PHP caller that
passed 'title,posts_per_page' got every setting back. REST requests
were not affected, because the run endpoint converts the input to the
schema types first.

Normalize the list with rest_sanitize_array().
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 7, 2026
Port the core docs change in WordPress/wordpress-develop#12141
(4620fbcaed). An array replaces the show_in_rest name and schema
entirely, so a setting that only sets a name for abilities also drops
its REST schema, such as an email format. Say so where Show_In_Abilities
describes the values it can set.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 7, 2026
Port the core test in WordPress/wordpress-develop#12141 (f5cf4af483).
show_in_abilities => true reuses the show_in_rest name and schema, so
guard the opt-in rule next to it: a setting shown in the REST API
without show_in_abilities is not exposed.
…alize are not exposed.

The abilities registry can initialize while `init` is still running,
for example when a plugin uses it at an earlier `init` priority, so the
docs were wrong to call registering a setting on `init` reliable. Say
that settings registered after abilities initialize are not exposed,
and drop the comments that only repeat that the exposed settings are
computed once.
…ttings endpoint.

It does not: it reads values with a plain `get_option()` call, without
the `rest_pre_get_setting` filter or the schema default that
`/wp/v2/settings` passes, and it leaves out a value the endpoint
answers with null. Describe what the ability does instead.
PHP turns a numeric array key, such as a setting named `123`, into an
integer. The `fields` enum then listed the integer, so input validation
rejected the name passed as a string, and the strict `in_array()` check
did not match it either. Cast the names to strings, and test with a
numeric setting name.
…nvalidates.

Stored values were only validated before sanitizing, but
`rest_sanitize_value_from_schema()` can turn a valid value into one the
schema rejects. For example, `sanitize_text_field()` turns the email
`%ab@x.co` into `@x.co`. Output validation then failed the whole call
instead of leaving out that one setting. Validate the sanitized value
too.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 7, 2026
… exposed

Port the core docs change in WordPress/wordpress-develop#12141
(89ca6e70f4). The abilities registry can initialize while init is still
running, for example when a plugin uses it at an earlier init priority,
so the docs were wrong to call registering a setting on init reliable.
Say that settings registered after the abilities register are not
exposed, and drop the comments that only repeat that the exposed
settings are computed once.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 7, 2026
Port the core change in WordPress/wordpress-develop#12141 (a4e7ea0948).
It does not: it reads values with a plain get_option() call, without
the rest_pre_get_setting filter or the schema default that
/wp/v2/settings passes, and it leaves out a value the endpoint answers
with null. Describe what the ability does instead.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 7, 2026
Port the core change in WordPress/wordpress-develop#12141 (f454c10bbf).
PHP turns a numeric array key, such as a setting named 123, into an
integer. The fields enum then listed the integer, so input validation
rejected the name passed as a string, and the strict in_array() check
did not match it either. Cast the names to strings, and test with a
numeric setting name.

core/settings-update builds its answer from the keys of the settings it
wrote, which are integers for such names, so pass them to
core/settings-get as strings too. Without that, the string check would
leave a numeric setting out of the answer.
jorgefilipecosta added a commit to WordPress/ai that referenced this pull request Oct 7, 2026
Port the core change in WordPress/wordpress-develop#12141 (a999491872).
Stored values were only validated before sanitizing, but
rest_sanitize_value_from_schema() can turn a valid value into one the
schema rejects. For example, sanitize_text_field() turns the email
%ab@x.co into @x.co. Output validation then failed the whole call
instead of leaving out that one setting. Validate the sanitized value
too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants