PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
← All changes | includes/modules/Mcp/Mcp_Tools.php +707 -65 1.2.4 → 1.4.0 View file →
@@ -29,8 +29,9 @@
29 29 use XSpeed\Server;
30 30 use XSpeed\Admin;
31 31 use XSpeed\Settings;
32 32 use XSpeed\Settings_Manager;
33 +use XSpeed\Module_Registry;
33 34 use XSpeed\Pro_Audit;
34 35 use XSpeed\Cache_Benchmark;
35 36 use XSpeed\Tier_Registry;
36 37 use XSpeed\Database_Cleaner;
@@ -37,11 +38,16 @@
37 38
38 39 defined( 'ABSPATH' ) || exit;
39 40
40 41 final class Mcp_Tools {
42 + /** Pro extension contract supported by this Free build. */
43 + public const EXTENSION_API = 1;
41 44
45 + /** Raw broker tool envelope cap, enforced before JSON decoding. */
46 + public const MAX_TOOL_BODY_BYTES = 2 * 1024 * 1024;
47 +
42 48 /** Valid cache purge types. */
43 - public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest' );
49 + public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest', 'cloudflare', 'cdn' );
44 50
45 51 /**
46 52 * Per-call read-only override. Null means "defer to the pairing token's
47 53 * scope" (the JSON-RPC path that predates OAuth). true/false is set by
@@ -138,15 +144,15 @@
138 144 'write' => false,
139 145 'handler' => array( self::class, 'list_modules' ),
140 146 ),
141 147 'get_site_info' => array(
142 - 'description' => 'Get facts about this site and install: whether xSpeed Pro is active and licensed, plugin/WordPress/PHP versions, and the detected web server. Use this rather than inferring the tier from the module list.',
148 + 'description' => 'Get facts about this site and install: whether xSpeed Pro is active and licensed, plugin/WordPress/PHP versions, and the detected web server. Use this rather than inferring the tier from the module list. `addons` maps each separately licensed add-on to `{licensed, status}`. `licensed` says whether the add-on\'s licence is active. `status` is the licence status the site has stored (`valid`, `expired`, `inactive`, and so on), or null when no key is stored. Both are read from stored state, never from the licence server. `addons` is `{}` when no add-on reports. A licensed add-on may still have nothing set up.',
143 149 'inputSchema' => self::object_schema( array(), array() ),
144 150 'write' => false,
145 151 'handler' => array( self::class, 'get_site_info' ),
146 152 ),
147 153 'optimize_site' => array(
148 - 'description' => 'Make this site faster, end to end: measure, apply the recommended settings ONE AT A TIME, check the page still renders after each, and undo any change that breaks it. Returns what was applied, the site\'s last recorded score, `next_steps` (riskier settings that could help but are NOT applied automatically), and `unfixable` (problems no caching plugin can reach). ALWAYS relay all three to the user: report the score and what is still wrong, then — if `next_steps` is non-empty — describe each one WITH its stated `risk` and ASK whether to run again with aggressiveness "aggressive". Never enable aggressive settings without the user agreeing first, and never present `unfixable` items as things you can solve; they need the site owner or the host. A site where nothing was left to do is a real, good answer — say so plainly rather than apologising or retrying. Use `dry_run` to preview the plan.',
154 + 'description' => 'Make this site faster, end to end: measure, apply the recommended settings ONE AT A TIME, check the page still renders after each, and undo any change that breaks it. Returns what was applied, the site\'s performance score, `next_steps` (riskier settings that could help but are NOT applied automatically), and `unfixable` (problems no caching plugin can reach). ALWAYS relay all three to the user: report the score and what is still wrong, then — if `next_steps` is non-empty — describe each one WITH its stated `risk` and ASK whether to run again with aggressiveness "aggressive". Never enable aggressive settings without the user agreeing first, and never present `unfixable` items as things you can solve; they need the site owner or the host. A site where nothing was left to do is a real, good answer — say so plainly rather than apologising or retrying. Use `dry_run` to preview the plan. The `score` object carries `age_seconds` and `stale`: quote the score WITH how recently it was measured, and never describe a stale score as the result this run produced — when a measurement could not be taken, say the number is old rather than implying it is current. WHEN CHANGES WERE APPLIED the response carries `verify_urls` and `verify_note`: the safety checks read HTML in PHP and cannot execute JavaScript, so a page can pass every one of them and still be broken in a browser. Before reporting success, OPEN each URL in `verify_urls` if you have any way to load a page and confirm it renders with no console errors; if you cannot, tell the user those URLs need checking and what a problem would look like. `verified: true` means the HTML checks passed — it is not a statement that the site works.',
149 155 'inputSchema' => self::object_schema(
150 156 array(
151 157 'aggressiveness' => array(
152 158 'type' => 'string',
@@ -156,8 +162,24 @@
156 162 'dry_run' => array(
157 163 'type' => 'boolean',
158 164 'description' => 'Return the plan without changing anything.',
159 165 ),
166 + 'measure_score' => array(
167 + 'type' => 'string',
168 + 'enum' => array( 'auto', 'never', 'always' ),
169 + 'description' => 'Whether to take a fresh PageSpeed measurement. auto (default) measures when the stored score is stale and after changes land; never reuses the stored score; always measures even for a dry run. Measurements are rate-limited, so a run inside the cooldown returns the stored score with its age rather than a new one.',
170 + ),
171 + 'target_score' => array(
172 + 'type' => 'integer',
173 + 'minimum' => 1,
174 + 'maximum' => 100,
175 + 'description' => 'Repeat the optimize cycle toward this score instead of running a single pass. Each round costs a real PageSpeed measurement and up to two minutes, so pass it only when the user asked for a specific number. Requires the iterative tuner; without it the run is a single pass and the report says so in `stopped_because`. The run also stops early when further rounds stop helping — either way, read `stopped_because` and relay it rather than retrying.',
176 + ),
177 + 'max_rounds' => array(
178 + 'type' => 'integer',
179 + 'minimum' => 1,
180 + 'description' => 'Ceiling on rounds when target_score is set. Clamped to what the tuner allows.',
181 + ),
160 182 ),
161 183 array()
162 184 ),
163 185 'write' => true,
@@ -175,9 +197,9 @@
175 197 'write' => false,
176 198 'handler' => array( self::class, 'get_pro_audit' ),
177 199 ),
178 200 'purge_cache' => array(
179 - 'description' => 'Purge the site cache. "type" selects what to purge: all, page, assets, object, or rest. Defaults to all.',
201 + 'description' => 'Purge the site cache and report what was actually cleared, what was skipped and why. "type" selects what to purge: all, page, assets, object, rest, cloudflare, or cdn. Defaults to all.',
180 202 'inputSchema' => self::object_schema(
181 203 array(
182 204 'type' => array(
183 205 'type' => 'string',
@@ -279,9 +301,9 @@
279 301 'write' => true,
280 302 'handler' => array( self::class, 'start_preloader' ),
281 303 ),
282 304 'run_score' => array(
283 - 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) against this site and return the score plus Core Web Vitals. Available on every install. Spends the site\'s own configured API quota. Use get_score_history to read past runs without starting a new one.',
305 + 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) against this site. Running it counts as the opt-in for external scores. With the site\'s own API key the score plus Core Web Vitals come back in the response; a keyless PageSpeed audit on a Hub-connected site is queued instead — the response says so, and the result lands in the history a minute or two later (poll with run_command "score status", or read get_score_history). Spends the site\'s own configured API quota when a key is set.',
284 306 'inputSchema' => self::object_schema(
285 307 array(
286 308 'target' => array(
287 309 'type' => 'string',
@@ -317,9 +339,9 @@
317 339 'write' => true,
318 340 'handler' => array( self::class, 'run_score' ),
319 341 ),
320 342 'run_pagespeed' => array(
321 - 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) and return the score + Core Web Vitals. Defaults to the site home page, mobile strategy. Requires external scores to be enabled in settings — the plugin makes no outbound calls otherwise.',
343 + 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) and return the score + Core Web Vitals. Defaults to the site home page, mobile strategy. Running it counts as the opt-in for external scores; a keyless PageSpeed audit routes through xSpeed Hub when the site is connected.',
322 344 'inputSchema' => self::object_schema(
323 345 array(
324 346 'url' => array(
325 347 'type' => 'string',
@@ -351,10 +373,18 @@
351 373 'write' => true,
352 374 'handler' => array( self::class, 'run_pagespeed' ),
353 375 ),
354 376 'generate_critical_css' => array(
355 - 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.',
356 - 'inputSchema' => self::object_schema( array(), array() ),
377 + 'description' => 'Generate above-the-fold Critical CSS for one page (Pro). Calls the external generator and stores the result for that page\'s template. Defaults to the site home page; pass url to build it for another page or template.',
378 + 'inputSchema' => self::object_schema(
379 + array(
380 + 'url' => array(
381 + 'type' => 'string',
382 + 'description' => 'Page on this site to generate Critical CSS for, as a full URL or a path such as /pricing/. Defaults to the site home page.',
383 + ),
384 + ),
385 + array()
386 + ),
357 387 'write' => true,
358 388 'handler' => array( self::class, 'generate_critical_css' ),
359 389 ),
360 390 'get_health' => array(
@@ -474,12 +504,20 @@
474 504 'toggle_object_cache' => array(
475 505 'description' => 'Enable or disable the object cache drop-in. Verify the backend with test_object_cache first — enabling against an unreachable server slows every request.',
476 506 'inputSchema' => self::object_schema(
477 507 array(
478 - 'enabled' => array(
508 + 'enabled' => array(
479 509 'type' => 'boolean',
480 510 'description' => 'true installs the drop-in, false removes it.',
481 511 ),
512 + 'takeover' => array(
513 + 'type' => 'boolean',
514 + 'description' => 'With enabled=true: switch from the plugin that owns object-cache.php. Without it, enabling refuses while another plugin owns the file.',
515 + ),
516 + 'restore' => array(
517 + 'type' => 'boolean',
518 + 'description' => 'With enabled=false: put back the plugin xSpeed switched from.',
519 + ),
482 520 ),
483 521 array( 'enabled' )
484 522 ),
485 523 'write' => true,
@@ -544,9 +582,9 @@
544 582 'write' => false,
545 583 'handler' => array( self::class, 'list_commands' ),
546 584 ),
547 585 'run_command' => array(
548 - 'description' => 'Run any xSpeed command — the full CLI surface (~50 commands across every module: cache, cloudflare, database, critical/unused CSS, pagespeed, images, migration, preloader, object cache, analytics, RUM, smart-* and more). Call list_commands first to discover names + options. Examples: run_command("cloudflare purge"), run_command("psi", {}, {"url":"https://site.com","strategy":"mobile"}). Permanently destructive commands ("database clean") additionally require a confirm_token from scan_database and are refused without one — this gateway is not a way around that confirmation.',
586 + 'description' => 'Run any xSpeed command — the full CLI surface (~50 commands across every module: cache, cloudflare, database, critical/unused CSS, pagespeed, images, migration, preloader, object cache, analytics, RUM, smart-* and more). Call list_commands first to discover names + options. Examples: run_command("cloudflare purge"), run_command("psi", {}, {"url":"https://site.com","strategy":"mobile"}). Permanently destructive commands additionally require a confirm_token and are refused without one — this gateway is not a way around that confirmation. "database clean" takes its token from scan_database, which previews exactly what would be deleted; every other destructive command is refused once and the refusal carries a token, so repeating the same call with it confirms the action.',
549 587 'inputSchema' => self::object_schema(
550 588 array(
551 589 'command' => array(
552 590 'type' => 'string',
@@ -562,9 +600,9 @@
562 600 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.',
563 601 ),
564 602 'confirm_token' => array(
565 603 'type' => 'string',
566 - 'description' => 'Required ONLY for permanently destructive commands such as "database clean". Obtain it from scan_database, which previews exactly what would be deleted. Without it those commands are refused.',
604 + 'description' => 'Required ONLY for permanently destructive commands. For "database clean" obtain it from scan_database, which previews exactly what would be deleted; for any other destructive command, make the call once without this argument and the refusal returns the token to repeat it with. Without it those commands are refused.',
567 605 ),
568 606 ),
569 607 array( 'command' )
570 608 ),
@@ -619,8 +657,9 @@
619 657 'update_settings' => 'xspeed settings',
620 658 'run_pagespeed' => 'xspeed psi',
621 659 'get_health' => 'xspeed health',
622 660 'get_score_history' => 'xspeed score',
661 + 'purge_cache' => 'xspeed purge',
623 662 );
624 663
625 664 $commands = Cli_Bridge::commands();
626 665 foreach ( $conditional as $tool => $command ) {
@@ -652,8 +691,49 @@
652 691 $catalog[ $name ] = $spec;
653 692 }
654 693 }
655 694
695 + /**
696 + * Add private tools owned by an installed extension.
697 + *
698 + * Extensions receive an EMPTY map and may only add new names. Core tool
699 + * definitions cannot be replaced through this seam. A `hidden` tool is
700 + * callable through the authenticated per-site proxy but omitted from
701 + * tools/list. This is for broker-run workflows whose public tool must not
702 + * be advertised until the broker-side worker exists.
703 + *
704 + * `hidden` is not a permission: the proxy takes the same pairing token
705 + * the public channel does. It withholds a tool from discovery and from
706 + * an OAuth grant, nothing more. See the gate in invoke().
707 + *
708 + * `hidden` must be a real bool when present. It decides whether a tool
709 + * is reachable from the public channel at all, so a truthy string is
710 + * the kind of near-miss that should be rejected rather than guessed at.
711 + * Specs that fail any check here are skipped, not repaired.
712 + *
713 + * @param array<string,array<string,mixed>> $tools Extension tool specs.
714 + */
715 + $extensions = apply_filters( 'xspeed_mcp_extension_tools', array() );
716 + if ( is_array( $extensions ) ) {
717 + foreach ( $extensions as $name => $spec ) {
718 + if (
719 + ! is_string( $name )
720 + || 1 !== preg_match( '/^[a-z][a-z0-9_]{0,63}$/', $name )
721 + || isset( $catalog[ $name ] )
722 + || ! is_array( $spec )
723 + || ! isset( $spec['description'], $spec['inputSchema'], $spec['handler'], $spec['write'] )
724 + || ! is_string( $spec['description'] )
725 + || ! is_array( $spec['inputSchema'] )
726 + || ! is_callable( $spec['handler'] )
727 + || ! is_bool( $spec['write'] )
728 + || ( isset( $spec['hidden'] ) && ! is_bool( $spec['hidden'] ) )
729 + ) {
730 + continue;
731 + }
732 + $catalog[ $name ] = $spec;
733 + }
734 + }
735 +
656 736 return $catalog;
657 737 }
658 738
659 739 /**
@@ -886,8 +966,11 @@
886 966 */
887 967 public static function list(): array {
888 968 $out = array();
889 969 foreach ( self::catalog() as $name => $spec ) {
970 + if ( ! empty( $spec['hidden'] ) ) {
971 + continue;
972 + }
890 973 $out[] = array(
891 974 'name' => $name,
892 975 'description' => $spec['description'],
893 976 'inputSchema' => $spec['inputSchema'],
@@ -926,8 +1009,39 @@
926 1009
927 1010 return $error;
928 1011 }
929 1012
1013 + // Hidden tools are private broker stages, not undiscoverable public MCP
1014 + // tools. Omitting one from tools/list is only presentation, so this
1015 + // closes the JSON-RPC channel to them as well, returning the same 404 a
1016 + // nonexistent name returns. Both entry paths set the channel before
1017 + // every invoke, so a previous broker call cannot widen a later one.
1018 + //
1019 + // What this is NOT: a permission boundary. The broker REST route
1020 + // authenticates with the SAME pairing token as the JSON-RPC route
1021 + // (Mcp_Auth::permission and Mcp_Server::authorize both compare against
1022 + // Mcp_Pairing::site_token()), so anyone holding that token — every
1023 + // client the dashboard's connection recipes are written for — can call
1024 + // a hidden tool by name on the broker route, and tell it from a
1025 + // nonexistent one by the status. `hidden` keeps a tool off the
1026 + // advertised surface and out of an OAuth grant's reach; it does not
1027 + // make it safe for a pairing-token holder to run. Anything gated only
1028 + // by `hidden` must be something that holder may already do.
1029 + if ( ! empty( $catalog[ $name ]['hidden'] ) && 'broker' !== self::$channel ) {
1030 + $error = new \WP_Error(
1031 + 'xspeed_mcp_unknown_tool',
1032 + sprintf(
1033 + /* translators: %s: tool name. */
1034 + __( 'Unknown tool: %s', 'xspeed' ),
1035 + $name
1036 + ),
1037 + array( 'status' => 404 )
1038 + );
1039 + Mcp_Activity_Log::record( $name, $args, false, $error->get_error_message(), 'write', self::$channel );
1040 +
1041 + return $error;
1042 + }
1043 +
930 1044 // Scope enforcement: a read-only connection cannot invoke a tool that
931 1045 // mutates state. run_command is a gateway to the full CLI surface, so
932 1046 // it's treated as write regardless of the wrapped command. The active
933 1047 // credential's scope (pairing token OR OAuth access token) is carried
@@ -962,16 +1076,32 @@
962 1076 * covers each door at once — including any future tool that wraps the
963 1077 * same command. (#184)
964 1078 */
965 1079 $destructive = self::destructive_action( $name, $args );
966 - if ( '' !== $destructive ) {
967 - $confirmed = self::verify_clean_token( $args );
1080 + if ( ! empty( $destructive ) ) {
1081 + $confirmed = self::verify_confirm_token( $args, $name, $destructive );
968 1082 if ( is_wp_error( $confirmed ) ) {
969 - Mcp_Activity_Log::record( $name, $args, false, $confirmed->get_error_message(), 'write', self::$channel );
1083 + // The refusal message can carry a freshly minted token (see
1084 + // confirm_required()); the log gets the redacted twin so a
1085 + // live token is never persisted in wp_options twice over.
1086 + Mcp_Activity_Log::record( $name, $args, false, self::loggable_error( $confirmed ), 'write', self::$channel );
970 1087 return $confirmed;
971 1088 }
972 1089 }
973 1090
1091 + /*
1092 + * The token authorised the call; it is not an argument any handler
1093 + * takes. A generated command tool forwards every property it does not
1094 + * recognise as a positional straight to Cli_Bridge::run() as a CLI
1095 + * option, so leaving it in place would turn a correctly confirmed
1096 + * `xspeed_cfe` call into `--confirm_token=…` and an unknown-option
1097 + * failure: the gate satisfied and the action still unreachable, which
1098 + * is the same broken feature by a later route. Dropped for every tool,
1099 + * not just the gated ones, and dropped before the audit record so a
1100 + * live token is never written into the activity log.
1101 + */
1102 + unset( $args['confirm_token'] );
1103 +
974 1104 self::$dispatching = true;
975 1105 try {
976 1106 $result = call_user_func( $catalog[ $name ]['handler'], $args );
977 1107
@@ -1048,8 +1178,13 @@
1048 1178
1049 1179 /**
1050 1180 * Cache status, stats, and detected server.
1051 1181 *
1182 + * `site_icon` is the Site Icon set under Appearance, or '' when none is
1183 + * set. The Hub shows it beside the site's name. Asking the site for
1184 + * /favicon.ico instead failed on nginx hosts, which answer .ico
1185 + * requests as static files and never reach WordPress.
1186 + *
1052 1187 * @param array $args Unused.
1053 1188 * @return array
1054 1189 */
1055 1190 public static function get_cache_status( array $args ) {
@@ -1058,8 +1193,9 @@
1058 1193 return array(
1059 1194 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ),
1060 1195 'stats' => Cache::get_stats(),
1061 1196 'server' => Server::type(),
1197 + 'site_icon' => (string) get_site_icon_url( 64 ),
1062 1198 );
1063 1199 }
1064 1200
1065 1201 /**
@@ -1132,12 +1268,93 @@
1132 1268 'wp_version' => get_bloginfo( 'version' ),
1133 1269 'php_version' => PHP_VERSION,
1134 1270 'server' => Server::type(),
1135 1271 'multisite' => is_multisite(),
1272 + 'addons' => self::addon_licences(),
1136 1273 );
1137 1274 }
1138 1275
1276 + /** Most add-on entries `get_site_info` reports. */
1277 + private const MAX_ADDONS = 20;
1278 +
1279 + /** Longest licence status kept. The library's statuses are short slugs. */
1280 + private const MAX_ADDON_STATUS_LENGTH = 40;
1281 +
1139 1282 /**
1283 + * The licence state of each separately licensed add-on, keyed by slug.
1284 + *
1285 + * Free cannot know which add-ons exist, so it asks. An add-on answers from
1286 + * the licence state it has stored. Callers poll this tool, and a
1287 + * licence-server round trip here would make every call wait on it.
1288 + *
1289 + * Always an object, `{}` when nothing answers. An empty PHP array encodes
1290 + * as `[]`, and a consumer reading `addons.some_slug` should not have to
1291 + * handle a list as well.
1292 + *
1293 + * @return object Slug => `{licensed: bool, status: ?string}`.
1294 + */
1295 + private static function addon_licences(): object {
1296 + /**
1297 + * Filter: xspeed_site_info_addons
1298 + *
1299 + * Licence state for add-ons sold separately, reported by the
1300 + * `get_site_info` MCP tool. Seeded empty; add your own entry and
1301 + * return the array:
1302 + *
1303 + * $addons['my_addon'] = array( 'licensed' => true, 'status' => 'valid' );
1304 + *
1305 + * `licensed` is whether the add-on may run. `status` is the licence
1306 + * status as stored, or null when no key is stored. Read stored state
1307 + * only, never the licence server. Keys go through sanitize_key(),
1308 + * statuses are trimmed to short slugs, and any other field is dropped.
1309 + *
1310 + * @param array $addons Entries gathered so far, keyed by slug.
1311 + */
1312 + try {
1313 + $raw = apply_filters( 'xspeed_site_info_addons', array() );
1314 + } catch ( \Throwable $e ) {
1315 + // A broken add-on costs the add-on entries, not the whole tool.
1316 + // The other facts are still true and the caller still needs them.
1317 + // Pop our hook off the stack the throw left it on, for the reason
1318 + // Pro_Audit::contributed() gives.
1319 + if ( isset( $GLOBALS['wp_current_filter'] )
1320 + && is_array( $GLOBALS['wp_current_filter'] )
1321 + && end( $GLOBALS['wp_current_filter'] ) === 'xspeed_site_info_addons' ) {
1322 + array_pop( $GLOBALS['wp_current_filter'] );
1323 + }
1324 + return (object) array();
1325 + }
1326 +
1327 + $out = array();
1328 + foreach ( is_array( $raw ) ? $raw : array() as $slug => $entry ) {
1329 + if ( count( $out ) >= self::MAX_ADDONS ) {
1330 + break;
1331 + }
1332 + if ( ! is_string( $slug ) || ! is_array( $entry ) ) {
1333 + continue;
1334 + }
1335 + $slug = sanitize_key( $slug );
1336 + if ( '' === $slug || isset( $out[ $slug ] ) ) {
1337 + continue;
1338 + }
1339 +
1340 + $status = $entry['status'] ?? null;
1341 + $status = is_string( $status )
1342 + ? substr( sanitize_key( $status ), 0, self::MAX_ADDON_STATUS_LENGTH )
1343 + : '';
1344 +
1345 + $out[ $slug ] = array(
1346 + 'licensed' => (bool) ( $entry['licensed'] ?? false ),
1347 + // An empty status says nothing a null does not, and two ways
1348 + // of saying "none" is one more case for every consumer.
1349 + 'status' => '' === $status ? null : $status,
1350 + );
1351 + }
1352 +
1353 + return (object) $out;
1354 + }
1355 +
1356 + /**
1140 1357 * All registered module descriptors.
1141 1358 *
1142 1359 * @param array $args Unused.
1143 1360 * @return array
@@ -1157,14 +1374,29 @@
1157 1374 * @param array<string,mixed> $args Tool arguments.
1158 1375 * @return array<string,mixed>|\WP_Error
1159 1376 */
1160 1377 public static function optimize_site( array $args = array() ) {
1161 - return \XSpeed\Optimize_Runner::run(
1162 - array(
1163 - 'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ),
1164 - 'dry_run' => (bool) ( $args['dry_run'] ?? false ),
1165 - )
1378 + $run = array(
1379 + 'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ),
1380 + 'dry_run' => (bool) ( $args['dry_run'] ?? false ),
1381 + 'measure_score' => (string) ( $args['measure_score'] ?? 'auto' ),
1166 1382 );
1383 +
1384 + // Tuning arguments are forwarded ONLY when present, and are not
1385 + // understood by Free — a listener on `xspeed_optimize_report` reads
1386 + // them from that filter's `$context`. Passing them through rather
1387 + // than naming them in the array above is deliberate: this handler
1388 + // builds an explicit whitelist, so an argument it does not list is
1389 + // silently dropped. A caller asking to reach a score would have got a
1390 + // single pass and a success response — wrong behaviour with no error,
1391 + // which is the expensive kind to diagnose.
1392 + foreach ( array( 'target_score', 'max_rounds' ) as $key ) {
1393 + if ( isset( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) {
1394 + $run[ $key ] = (int) $args[ $key ];
1395 + }
1396 + }
1397 +
1398 + return \XSpeed\Optimize_Runner::run( $run );
1167 1399 }
1168 1400
1169 1401 /**
1170 1402 * Before/after cache benchmark timings.
@@ -1212,12 +1444,43 @@
1212 1444 }
1213 1445 // Named source, not the default "manual": the purge log's whole job
1214 1446 // is to let an admin see that the cache cleared because an assistant
1215 1447 // asked, not because someone clicked.
1216 - $count = Cache::purge_type( $type, __( 'AI assistant', 'xspeed' ) );
1448 + $cause = __( 'AI assistant', 'xspeed' );
1449 +
1450 + /*
1451 + * `page`, `assets` and `rest` are fine-grained slices of the local
1452 + * sweep with no target of their own, and they predate this tool's
1453 + * per-store report — an assistant asking for `page` means the HTML,
1454 + * not the HTML plus the minified bundles plus every purge listener.
1455 + * They stay on purge_type() so their meaning does not change under
1456 + * callers already relying on it.
1457 + *
1458 + * Everything else routes through the runner — the same core function
1459 + * the CLI and the REST callback use — so an assistant told "cache
1460 + * cleared" is reading the same per-store verdict a human would get,
1461 + * including a Cloudflare zone that refused the purge.
1462 + */
1463 + if ( in_array( $type, array( 'page', 'assets', 'rest' ), true ) ) {
1464 + return array(
1465 + 'purged' => $type,
1466 + 'count' => Cache::purge_type( $type, $cause ),
1467 + 'ok' => true,
1468 + 'stats' => Cache::get_stats(),
1469 + );
1470 + }
1471 +
1472 + $report = \XSpeed\Purge_Runner::run( array( $type ), $cause );
1473 + $count = 0;
1474 + foreach ( $report['types'] as $row ) {
1475 + $count += (int) $row['entries'];
1476 + }
1477 +
1217 1478 return array(
1218 1479 'purged' => $type,
1219 1480 'count' => $count,
1481 + 'ok' => $report['ok'],
1482 + 'report' => $report['types'],
1220 1483 'stats' => Cache::get_stats(),
1221 1484 );
1222 1485 }
1223 1486
@@ -1497,13 +1760,26 @@
1497 1760 * @return true|\WP_Error True when every key would be applied.
1498 1761 */
1499 1762 private static function inspect_or_error( string $module, array $values ) {
1500 1763 $report = Settings_Manager::inspect_input( $module, $values );
1501 - if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) ) {
1764 + if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) && empty( $report['locked'] ) ) {
1502 1765 return true;
1503 1766 }
1504 1767
1505 1768 $parts = array();
1769 + // A field pinned by a wp-config.php constant cannot be written. Say so
1770 + // rather than returning a success the agent relays as "changed" over a
1771 + // write that update() would silently drop. (#398)
1772 + foreach ( $report['locked'] as $key ) {
1773 + $spec = ( Module_Registry::get( $module ) ? Module_Registry::get( $module )->settings_schema()[ $key ] ?? array() : array() );
1774 + $constant = Settings_Manager::effective_constant( $module, $key, is_array( $spec ) ? $spec : array() );
1775 + $parts[] = sprintf(
1776 + /* translators: 1: setting key, 2: wp-config.php constant name. */
1777 + __( '"%1$s" is defined in wp-config.php as %2$s and cannot be changed here', 'xspeed' ),
1778 + $key,
1779 + (string) $constant
1780 + );
1781 + }
1506 1782 foreach ( $report['unknown'] as $key ) {
1507 1783 $detail = sprintf(
1508 1784 /* translators: 1: setting key, 2: module slug. */
1509 1785 __( '"%1$s" is not a setting of module "%2$s"', 'xspeed' ),
@@ -1544,8 +1820,9 @@
1544 1820 array(
1545 1821 'status' => 400,
1546 1822 'refused_unknown' => $report['unknown'],
1547 1823 'refused_invalid' => $report['invalid'],
1824 + 'refused_locked' => $report['locked'],
1548 1825 'would_apply' => $report['applied'],
1549 1826 )
1550 1827 );
1551 1828 }
@@ -1621,9 +1898,9 @@
1621 1898 * longer describes reality and clean_database refuses. That closes the
1622 1899 * window where a scan is shown to a human, something changes, and the
1623 1900 * delete removes more than was agreed to. (#184)
1624 1901 */
1625 - $result['confirm_token'] = self::mint_clean_token();
1902 + $result['confirm_token'] = self::mint_confirm_token( self::clean_fingerprint() );
1626 1903 $result['confirm_note'] = __( 'This preview deletes nothing. To delete what is listed, call clean_database with this confirm_token. It expires in 5 minutes and stops working if the database changes.', 'xspeed' );
1627 1904
1628 1905 return $result;
1629 1906 }
@@ -1668,76 +1945,215 @@
1668 1945 );
1669 1946 }
1670 1947
1671 1948 /**
1672 - * Name the destructive action a call would run, or '' if it is harmless.
1949 + * Name the destructive action a call would run, or an empty array if it
1950 + * is harmless.
1673 1951 *
1674 1952 * @param string $name Tool name.
1675 1953 * @param array $args Decoded tool arguments.
1676 - * @return string Canonical "<command> <action>", or '' when not destructive.
1954 + * @return array{name?:string,action?:string} The classified pair, or array() when not destructive.
1677 1955 */
1678 - private static function destructive_action( string $name, array $args ): string {
1956 + private static function destructive_action( string $name, array $args ): array {
1679 1957 // The gateway carries the real command in its arguments; a typed tool
1680 1958 // is identified by the command it is mapped to.
1681 1959 if ( 'run_command' === $name ) {
1682 1960 $command = isset( $args['command'] ) ? (string) $args['command'] : '';
1683 1961 if ( '' === $command ) {
1684 - return '';
1962 + return array();
1685 1963 }
1686 1964 $positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array();
1687 - $resolved = Cli_Bridge::classify( $command, $positional );
1688 - } elseif ( 'clean_database' === $name ) {
1689 - $resolved = Cli_Bridge::classify( 'db', array( 'clean' ) );
1690 - } else {
1965 + return self::destructive_hit( Cli_Bridge::classify( $command, $positional ) );
1966 + }
1967 +
1968 + if ( 'clean_database' === $name ) {
1969 + return self::destructive_hit( Cli_Bridge::classify( 'db', array( 'clean' ) ) );
1970 + }
1971 +
1972 + /*
1973 + * The third door: the tool generated for every registered CLI command.
1974 + *
1975 + * run_command and clean_database were the two names this guard knew,
1976 + * so a command whose destructive action the filter names was still
1977 + * reachable without a confirm_token by calling its own generated tool
1978 + * — `xspeed_cfe` with action `remove` ran what
1979 + * run_command("cfe", ["remove"]) would have refused. The generated
1980 + * tool takes its action as the `action` property, so resolve the name
1981 + * back to a command and classify that action exactly as the gateway
1982 + * classifies its own.
1983 + */
1984 + $action = isset( $args['action'] ) ? (string) $args['action'] : '';
1985 + if ( '' === $action ) {
1986 + return array();
1987 + }
1988 +
1989 + $candidates = self::cli_commands_by_tool_name()[ $name ] ?? array();
1990 + if ( empty( $candidates ) ) {
1991 + /*
1992 + * The map has no entry for this tool name. Two ways to get here
1993 + * and neither may answer "harmless":
1994 + *
1995 + * - the registry is unavailable (an unbooted module, a call
1996 + * arriving before modules register, a bare unit-test context);
1997 + * - the registry is POPULATED but does not contain the command
1998 + * this tool name came from — Cli_Bridge::commands() memoises
1999 + * in a function-local static with no reset, so a module that
2000 + * registers its commands after the first call is invisible to
2001 + * the map while its generated tool is still dispatchable.
2002 + *
2003 + * This condition used to also require `array() === commands()`,
2004 + * which made the second case fall through with no candidates at
2005 + * all and return "not destructive" — the gate opening precisely
2006 + * where it was least able to see. Fall back on the naive inverse
2007 + * of cli_tool_name() in both cases. It is the mapping that can be
2008 + * wrong (underscores in a command name), but wrong here means
2009 + * asking for a confirm_token that was not strictly needed, never
2010 + * skipping one that was.
2011 + */
2012 + $candidates = array( str_replace( '_', ' ', $name ) );
2013 + }
2014 +
2015 + foreach ( $candidates as $command ) {
2016 + $hit = self::destructive_hit( Cli_Bridge::classify( $command, array( $action ) ) );
2017 + if ( ! empty( $hit ) ) {
2018 + return $hit;
2019 + }
2020 + }
2021 +
2022 + return array();
2023 + }
2024 +
2025 + /**
2026 + * Render a classified pair as the canonical "<command> <action>".
2027 + *
2028 + * @param array{name?:string,action?:string} $action Pair from destructive_action().
2029 + * @return string Canonical name, or '' for the empty (harmless) pair.
2030 + */
2031 + private static function canonical_action( array $action ): string {
2032 + if ( empty( $action['name'] ) ) {
1691 2033 return '';
1692 2034 }
2035 + return trim( $action['name'] . ' ' . ( $action['action'] ?? '' ) );
2036 + }
1693 2037
2038 + /**
2039 + * Is a classified (command, action) pair one the filter calls destructive?
2040 + *
2041 + * @param array{name:string,action:string} $resolved Output of Cli_Bridge::classify().
2042 + * @return array{name?:string,action?:string} The pair, or array() when not destructive.
2043 + */
2044 + private static function destructive_hit( array $resolved ): array {
1694 2045 if ( '' === $resolved['name'] ) {
1695 - return '';
2046 + return array();
1696 2047 }
1697 2048
1698 2049 $destructive = self::destructive_actions();
1699 2050 if ( ! isset( $destructive[ $resolved['name'] ] ) ) {
1700 - return '';
2051 + return array();
1701 2052 }
1702 2053 if ( ! in_array( $resolved['action'], (array) $destructive[ $resolved['name'] ], true ) ) {
1703 - return '';
2054 + return array();
1704 2055 }
1705 2056
1706 - return trim( $resolved['name'] . ' ' . $resolved['action'] );
2057 + return array(
2058 + 'name' => $resolved['name'],
2059 + 'action' => $resolved['action'],
2060 + );
1707 2061 }
1708 2062
1709 2063 /**
1710 - * Verify (and consume) the confirm_token minted by scan_database.
2064 + * Which CLI command(s) a generated tool name could have come from.
1711 2065 *
1712 - * @param array $args Decoded tool arguments.
2066 + * cli_tool_name() replaces spaces with underscores, which is not
2067 + * injective: a command named "xspeed foo bar" and one named
2068 + * "xspeed foo_bar" produce the same tool name, and splitting the tool
2069 + * name back on underscores cannot tell them apart. So the map is built
2070 + * forwards — cli_tool_name() applied to every registered command, the
2071 + * same function over the same input set that named the tools in the
2072 + * first place. No Free command carries an underscore today; a Pro or
2073 + * third-party module registering one must not quietly disarm this guard.
2074 + *
2075 + * A tool name claimed by two commands keeps both, and the caller treats
2076 + * the call as destructive if any of them is — the fail-closed direction,
2077 + * the same reasoning Cli_Bridge::classify() applies when the registry
2078 + * cannot resolve an input at all.
2079 + *
2080 + * @return array<string,string[]> Tool name => the commands that produce it.
2081 + */
2082 + private static function cli_commands_by_tool_name(): array {
2083 + $map = array();
2084 + foreach ( array_keys( Cli_Bridge::commands() ) as $command ) {
2085 + $tool_name = self::cli_tool_name( (string) $command );
2086 + if ( '' === $tool_name ) {
2087 + continue;
2088 + }
2089 + $map[ $tool_name ][] = (string) $command;
2090 + }
2091 + return $map;
2092 + }
2093 +
2094 + /**
2095 + * Marker: this build gates destructive command actions on a confirm_token
2096 + * whichever tool they are reached through, generated tools included.
2097 + *
2098 + * Add-ons that register a destructive CLI action need to know whether the
2099 + * host plugin will demand the second factor for them. Where this method
2100 + * is absent they have to refuse the action themselves when it arrives
2101 + * from anywhere but the dashboard or real wp-cli; where it answers true
2102 + * they can let the confirmation gate do the work. Keep it — an add-on
2103 + * keys its refusal on the absence, so removing it is a behaviour change
2104 + * in another plugin.
2105 + */
2106 + public static function supports_command_confirmation(): bool {
2107 + return true;
2108 + }
2109 +
2110 + /**
2111 + * The one destructive action whose token comes from a preview tool.
2112 + *
2113 + * `scan_database` is the only surface that shows what a delete would
2114 + * remove, so its token is sealed to that preview and nothing else may
2115 + * mint one — including `xspeed_db` with `action: clean`, which is just
2116 + * another door onto the same rows.
2117 + */
2118 + private const PREVIEW_CONFIRMED_ACTION = 'xspeed db clean';
2119 +
2120 + /**
2121 + * Verify (and consume) the confirm_token for a destructive action.
2122 + *
2123 + * @param array $args Decoded tool arguments.
2124 + * @param string $tool Tool name the call arrived on.
2125 + * @param array $action Classified pair from destructive_action().
1713 2126 * @return true|\WP_Error
1714 2127 */
1715 - private static function verify_clean_token( array $args ) {
2128 + private static function verify_confirm_token( array $args, string $tool, array $action ) {
1716 2129 $token = isset( $args['confirm_token'] ) ? (string) $args['confirm_token'] : '';
1717 2130 if ( '' === $token ) {
1718 - return new \WP_Error(
1719 - 'xspeed_mcp_confirm_required',
1720 - __( 'This permanently deletes content and cannot be undone. Call scan_database first to see exactly what would be removed, then pass the confirm_token it returns.', 'xspeed' ),
1721 - array( 'status' => 400 )
1722 - );
2131 + return self::confirm_required( $tool, $action );
1723 2132 }
1724 2133
1725 - // Single use: consumed whether or not the delete goes ahead, so one
1726 - // approval can never authorise a second, different deletion.
1727 - $sealed = self::consume_clean_token( $token );
2134 + // Computed only now: for the database clean the fingerprint is a full
2135 + // bloat scan, and a call refused for having no token at all should not
2136 + // pay for one.
2137 + $expected = self::confirm_fingerprint( $action );
2138 +
2139 + // Single use: consumed whether or not the action goes ahead, so one
2140 + // approval can never authorise a second, different call.
2141 + $sealed = self::consume_confirm_token( $token );
1728 2142 if ( '' === $sealed ) {
1729 2143 return new \WP_Error(
1730 2144 'xspeed_mcp_confirm_invalid',
1731 - __( 'That confirm_token is unknown or has expired (they last 5 minutes). Run scan_database again and use the fresh token.', 'xspeed' ),
2145 + __( 'That confirm_token is unknown or has expired (they last 5 minutes). Ask for a fresh one by making the same call without a confirm_token.', 'xspeed' ),
1732 2146 array( 'status' => 400 )
1733 2147 );
1734 2148 }
1735 2149
1736 - if ( ! hash_equals( $sealed, self::clean_fingerprint() ) ) {
2150 + if ( ! hash_equals( $sealed, $expected ) ) {
1737 2151 return new \WP_Error(
1738 2152 'xspeed_mcp_confirm_stale',
1739 - __( 'The database changed since that scan, so the preview no longer describes what would be deleted. Run scan_database again and confirm against the new result.', 'xspeed' ),
2153 + self::PREVIEW_CONFIRMED_ACTION === self::canonical_action( $action )
2154 + ? __( 'The database changed since that scan, so the preview no longer describes what would be deleted. Run scan_database again and confirm against the new result.', 'xspeed' )
2155 + : __( 'That confirm_token was issued for a different action, so it does not authorise this one. Repeat this exact call without a confirm_token to get one that does.', 'xspeed' ),
1740 2156 array( 'status' => 409 )
1741 2157 );
1742 2158 }
1743 2159
@@ -1743,18 +2159,160 @@
1743 2159
1744 2160 return true;
1745 2161 }
1746 2162
1747 - /** Fingerprint of the scope, so a token cannot outlive what it described. */
2163 + /**
2164 + * Refuse a destructive call that arrived without a confirm_token.
2165 + *
2166 + * Two shapes, because the two kinds of destructive action differ in where
2167 + * a token can honestly come from.
2168 + *
2169 + * `xspeed db clean` has a preview — `scan_database` — and the token is
2170 + * sealed to what that preview showed. Refuse and point at it; minting one
2171 + * here would authorise a delete nobody had been shown.
2172 + *
2173 + * Every other destructive action has no preview, and until now borrowed
2174 + * the database scope: the caller was told to "call scan_database first"
2175 + * for a token sealed to a fingerprint of row counts, which
2176 + * verify_confirm_token() would then reject as not matching. On an install
2177 + * where `xspeed db` is not registered there was no minting path at all,
2178 + * so the gate could not be passed by any sequence of calls. A guard that
2179 + * cannot be satisfied is a broken feature, and the pressure it creates is
2180 + * to delete the guard. (#184)
2181 + *
2182 + * So the refusal itself mints the token, sealed to this action. That is
2183 + * not authentication — the connection is already authenticated and holds
2184 + * write scope — it is a deliberate second round trip: the caller has to
2185 + * read a message naming what it is about to destroy and decide to send
2186 + * the call again. The token is single use, expires in five minutes, and
2187 + * confirms nothing but the action it names.
2188 + *
2189 + * The token travels in the MESSAGE, not just the error data:
2190 + * Mcp_Server::call_tool() renders a WP_Error as its message text alone
2191 + * and drops the data, so a token that lived only in the data would never
2192 + * reach the caller and the gate would stay unsatisfiable over MCP.
2193 + *
2194 + * @param string $tool Tool name the call arrived on.
2195 + * @param array $action Classified pair from destructive_action().
2196 + * @return \WP_Error
2197 + */
2198 + private static function confirm_required( string $tool, array $action ): \WP_Error {
2199 + $canonical = self::canonical_action( $action );
2200 +
2201 + if ( self::PREVIEW_CONFIRMED_ACTION === $canonical ) {
2202 + return new \WP_Error(
2203 + 'xspeed_mcp_confirm_required',
2204 + __( 'This permanently deletes content and cannot be undone. Call scan_database first to see exactly what would be removed, then pass the confirm_token it returns.', 'xspeed' ),
2205 + array(
2206 + 'status' => 400,
2207 + 'action' => $canonical,
2208 + 'log_message' => sprintf( 'Refused %s: no confirm_token (scan_database mints it).', $canonical ),
2209 + )
2210 + );
2211 + }
2212 +
2213 + $token = self::mint_confirm_token( self::confirm_fingerprint( $action ) );
2214 +
2215 + $repeat = 'run_command' === $tool
2216 + ? sprintf(
2217 + /* translators: %s: the confirm_token to send back. */
2218 + __( 'Call run_command again with the same command plus confirm_token: %s', 'xspeed' ),
2219 + $token
2220 + )
2221 + : sprintf(
2222 + /* translators: 1: tool name, 2: action value, 3: the confirm_token to send back. */
2223 + __( 'Call %1$s again with action: %2$s plus confirm_token: %3$s', 'xspeed' ),
2224 + $tool,
2225 + (string) ( $action['action'] ?? '' ),
2226 + $token
2227 + );
2228 +
2229 + return new \WP_Error(
2230 + 'xspeed_mcp_confirm_required',
2231 + sprintf(
2232 + /* translators: 1: canonical "<command> <action>", 2: how to repeat the call. */
2233 + __( '"%1$s" is destructive and cannot be undone, so it needs a second, deliberate call. %2$s — the token is single use and expires in 5 minutes.', 'xspeed' ),
2234 + $canonical,
2235 + $repeat
2236 + ),
2237 + array(
2238 + 'status' => 400,
2239 + 'action' => $canonical,
2240 + 'confirm_token' => $token,
2241 + 'expires_in' => self::CONFIRM_TOKEN_TTL,
2242 + 'log_message' => sprintf( 'Refused %s: awaiting confirm_token.', $canonical ),
2243 + )
2244 + );
2245 + }
2246 +
2247 + /**
2248 + * The activity-log text for a refusal.
2249 + *
2250 + * A refusal that mints a token puts that token in its message, because
2251 + * that is the only channel the caller can read it on. The activity log is
2252 + * a different audience and a durable one, so it takes the redacted twin
2253 + * the error carries alongside.
2254 + *
2255 + * @param \WP_Error $error Refusal to describe.
2256 + * @return string
2257 + */
2258 + private static function loggable_error( \WP_Error $error ): string {
2259 + $data = $error->get_error_data();
2260 + if ( is_array( $data ) && ! empty( $data['log_message'] ) ) {
2261 + return (string) $data['log_message'];
2262 + }
2263 + return $error->get_error_message();
2264 + }
2265 +
2266 + /**
2267 + * The scope a confirm_token for this action is sealed to.
2268 + *
2269 + * `xspeed db clean` is sealed to the preview: the categories enabled and
2270 + * the counts found, so the token dies the moment the database stops
2271 + * matching what the operator was shown.
2272 + *
2273 + * Every other action is sealed to itself — the (command, action) pair —
2274 + * because there is nothing else to bind to and binding to the database
2275 + * would make the token both meaningless (it proves nothing about the
2276 + * action) and fragile (any unrelated write invalidates it). A token for
2277 + * `xspeed cfe pause` therefore does not confirm `xspeed cfe remove`.
2278 + *
2279 + * @param array $action Classified pair from destructive_action().
2280 + * @return string
2281 + */
2282 + private static function confirm_fingerprint( array $action ): string {
2283 + if ( self::PREVIEW_CONFIRMED_ACTION === self::canonical_action( $action ) ) {
2284 + return self::clean_fingerprint();
2285 + }
2286 +
2287 + return hash(
2288 + 'sha256',
2289 + (string) wp_json_encode(
2290 + array(
2291 + (string) ( $action['name'] ?? '' ),
2292 + (string) ( $action['action'] ?? '' ),
2293 + )
2294 + )
2295 + );
2296 + }
2297 +
2298 + /** Fingerprint of the scan scope, so a token cannot outlive what it described. */
1748 2299 private static function clean_fingerprint(): string {
1749 2300 return hash( 'sha256', (string) wp_json_encode( self::clean_scope() ) );
1750 2301 }
1751 2302
1752 2303 /** Lifetime of a confirm_token, from mint to refusal. */
1753 - private const CLEAN_TOKEN_TTL = 5 * MINUTE_IN_SECONDS;
2304 + private const CONFIRM_TOKEN_TTL = 5 * MINUTE_IN_SECONDS;
1754 2305
1755 - /** Storage key for a minted token (the token itself is never stored). */
1756 - private static function clean_token_key( string $token ): string {
2306 + /**
2307 + * Storage key for a minted token (the token itself is never stored).
2308 + *
2309 + * The `xspeed_mcp_clean_` prefix predates tokens for actions other than
2310 + * the database clean. It is the on-disk format, and purge_expired_confirm_
2311 + * tokens() sweeps by exactly this prefix, so renaming it would strand
2312 + * every token minted by the running build.
2313 + */
2314 + private static function confirm_token_key( string $token ): string {
1757 2315 return 'xspeed_mcp_clean_' . hash( 'sha256', $token );
1758 2316 }
1759 2317
1760 2318 /*
@@ -1780,18 +2338,24 @@
1780 2338 * object cache does. Expiry is carried in the stored value and checked on
1781 2339 * read, since options have no TTL of their own.
1782 2340 */
1783 2341
1784 - private static function mint_clean_token(): string {
2342 + /**
2343 + * Mint a single-use token sealed to a scope fingerprint.
2344 + *
2345 + * @param string $fingerprint Scope the token confirms — see confirm_fingerprint().
2346 + * @return string
2347 + */
2348 + private static function mint_confirm_token( string $fingerprint ): string {
1785 2349 $token = wp_generate_password( 32, false );
1786 2350
1787 2351 // autoload=no: this is read once, by one request, minutes from now.
1788 2352 add_option(
1789 - self::clean_token_key( $token ),
2353 + self::confirm_token_key( $token ),
1790 2354 wp_json_encode(
1791 2355 array(
1792 - 'fingerprint' => self::clean_fingerprint(),
1793 - 'expires' => time() + self::CLEAN_TOKEN_TTL,
2356 + 'fingerprint' => $fingerprint,
2357 + 'expires' => time() + self::CONFIRM_TOKEN_TTL,
1794 2358 )
1795 2359 ),
1796 2360 '',
1797 2361 'no'
@@ -1796,9 +2360,9 @@
1796 2360 '',
1797 2361 'no'
1798 2362 );
1799 2363
1800 - self::purge_expired_clean_tokens();
2364 + self::purge_expired_confirm_tokens();
1801 2365
1802 2366 return $token;
1803 2367 }
1804 2368
@@ -1807,17 +2371,61 @@
1807 2371 *
1808 2372 * Consumes the record either way: a token is single use, so one approval
1809 2373 * can never authorise a second, different deletion.
1810 2374 */
1811 - private static function consume_clean_token( string $token ): string {
1812 - $key = self::clean_token_key( $token );
2375 + private static function consume_confirm_token( string $token ): string {
2376 + global $wpdb;
2377 +
2378 + $key = self::confirm_token_key( $token );
1813 2379 $stored = get_option( $key );
1814 2380 if ( ! is_string( $stored ) || '' === $stored ) {
1815 2381 return '';
1816 2382 }
1817 2383
1818 - delete_option( $key );
2384 + /*
2385 + * The claim is the DELETE, and nothing before it.
2386 + *
2387 + * This used to read the option, decide it existed, and then call
2388 + * delete_option() — check-then-act, with the whole verification
2389 + * sitting inside the gap. Twenty calls carrying one token, arriving
2390 + * together, all read the row before any of them removed it, and all
2391 + * twenty passed. QA measured six getting through and the database
2392 + * clean running six times. A persistent object cache widens it
2393 + * further: get_option() keeps answering from cache after the row is
2394 + * gone, so the read cannot be the gate under any timing.
2395 + *
2396 + * A single DELETE is the only step here MySQL makes atomic. InnoDB
2397 + * takes a row lock, exactly one statement reports a row affected, and
2398 + * every other concurrent caller sees zero however they got here. So
2399 + * the row's disappearance IS the permission, and the read above is
2400 + * demoted to what it should always have been — a way to recover the
2401 + * fingerprint, not evidence of anything.
2402 + *
2403 + * Raw rather than delete_option() because delete_option() reports
2404 + * whether it thinks a row existed, not whether THIS caller removed
2405 + * it, and it decides that from a cache. rows_affected comes from the
2406 + * server.
2407 + */
2408 + // No database, no atomic claim, no confirmation. Refusing here costs a
2409 + // caller one retry; the alternative is granting permission on the
2410 + // strength of a read that was never proof of anything.
2411 + if ( ! $wpdb instanceof \wpdb && ! is_object( $wpdb ) ) {
2412 + return '';
2413 + }
1819 2414
2415 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the atomic claim needs rows_affected from the server; the cache entry is dropped right after.
2416 + $claimed = $wpdb->query(
2417 + $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $key )
2418 + );
2419 +
2420 + // Whoever won, the row is gone for everyone; a cache still holding it
2421 + // would let a later read look live.
2422 + wp_cache_delete( $key, 'options' );
2423 +
2424 + if ( 1 !== (int) $claimed ) {
2425 + return '';
2426 + }
2427 +
1820 2428 $data = json_decode( $stored, true );
1821 2429 if ( ! is_array( $data ) || empty( $data['fingerprint'] ) ) {
1822 2430 return '';
1823 2431 }
@@ -1834,9 +2442,9 @@
1834 2442 * Options have no TTL, so an unused token would otherwise sit in
1835 2443 * wp_options forever — a scan that is never followed by a clean is the
1836 2444 * normal case, not the exception.
1837 2445 */
1838 - private static function purge_expired_clean_tokens(): void {
2446 + private static function purge_expired_confirm_tokens(): void {
1839 2447 global $wpdb;
1840 2448
1841 2449 if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
1842 2450 return;
@@ -2045,9 +2653,16 @@
2045 2653 $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
2046 2654 if ( null === $on ) {
2047 2655 return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
2048 2656 }
2049 - return Cli_Bridge::run( 'objcache', array( $on ? 'enable' : 'disable' ) );
2657 + $assoc = array();
2658 + if ( $on && ! empty( $args['takeover'] ) && filter_var( $args['takeover'], FILTER_VALIDATE_BOOLEAN ) ) {
2659 + $assoc['takeover'] = true;
2660 + }
2661 + if ( ! $on && ! empty( $args['restore'] ) && filter_var( $args['restore'], FILTER_VALIDATE_BOOLEAN ) ) {
2662 + $assoc['restore'] = true;
2663 + }
2664 + return Cli_Bridge::run( 'objcache', array( $on ? 'enable' : 'disable' ), $assoc );
2050 2665 }
2051 2666
2052 2667 /**
2053 2668 * List or clear stored Critical CSS.
@@ -2290,14 +2905,41 @@
2290 2905
2291 2906 /**
2292 2907 * Generate Critical CSS (Pro).
2293 2908 *
2294 - * @param array $args Unused.
2909 + * The tool took no arguments, so it could only build the home page's
2910 + * blob, while `wp xspeed ccss generate --page-url` could target any page.
2911 + * Passed as `page-url`: `url` is a WP-CLI global the command never sees
2912 + * from a real command line. (#559)
2913 + *
2914 + * @param array $args { url?:string } Full URL or site path.
2295 2915 * @return array|\WP_Error
2296 2916 */
2297 2917 public static function generate_critical_css( array $args ) {
2298 - unset( $args );
2299 - return Cli_Bridge::run( 'ccss', array( 'generate' ) );
2918 + $options = array();
2919 + if ( ! empty( $args['url'] ) ) {
2920 + $url = trim( (string) $args['url'] );
2921 + // A site path is a page on this site, as it is for run_pagespeed.
2922 + if ( '/' === substr( $url, 0, 1 ) && '//' !== substr( $url, 0, 2 ) ) {
2923 + $url = home_url( $url );
2924 + } elseif ( ! preg_match( '#^https?://#i', $url ) ) {
2925 + $url = ''; // `about/`, `//host/x`: neither a path nor a full URL.
2926 + }
2927 + $url = '' === $url ? '' : esc_url_raw( $url, array( 'http', 'https' ) );
2928 + // Only pages of this site. A render spends the site's quota, and
2929 + // another host is not a page this site's Critical CSS can serve.
2930 + $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
2931 + if ( '' !== $url && strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) !== $host ) {
2932 + $url = '';
2933 + }
2934 + // Refused rather than dropped: an empty value would quietly
2935 + // build the home page and report success for the wrong page.
2936 + if ( '' === $url ) {
2937 + return new \WP_Error( 'xspeed_mcp_invalid_url', __( 'The url argument must be a page on this site, as a full http(s) URL or a path starting with /.', 'xspeed' ), array( 'status' => 400 ) );
2938 + }
2939 + $options['page-url'] = $url;
2940 + }
2941 + return Cli_Bridge::run( 'ccss', array( 'generate' ), $options );
2300 2942 }
2301 2943
2302 2944 /**
2303 2945 * Build a JSON Schema object node.