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 +617 -53 1.3.5 → 1.4.0 View file →
@@ -38,9 +38,14 @@
38 38
39 39 defined( 'ABSPATH' ) || exit;
40 40
41 41 final class Mcp_Tools {
42 + /** Pro extension contract supported by this Free build. */
43 + public const EXTENSION_API = 1;
42 44
45 + /** Raw broker tool envelope cap, enforced before JSON decoding. */
46 + public const MAX_TOOL_BODY_BYTES = 2 * 1024 * 1024;
47 +
43 48 /** Valid cache purge types. */
44 49 public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest', 'cloudflare', 'cdn' );
45 50
46 51 /**
@@ -139,9 +144,9 @@
139 144 'write' => false,
140 145 'handler' => array( self::class, 'list_modules' ),
141 146 ),
142 147 'get_site_info' => array(
143 - '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.',
144 149 'inputSchema' => self::object_schema( array(), array() ),
145 150 'write' => false,
146 151 'handler' => array( self::class, 'get_site_info' ),
147 152 ),
@@ -368,10 +373,18 @@
368 373 'write' => true,
369 374 'handler' => array( self::class, 'run_pagespeed' ),
370 375 ),
371 376 'generate_critical_css' => array(
372 - 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.',
373 - '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 + ),
374 387 'write' => true,
375 388 'handler' => array( self::class, 'generate_critical_css' ),
376 389 ),
377 390 'get_health' => array(
@@ -491,12 +504,20 @@
491 504 'toggle_object_cache' => array(
492 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.',
493 506 'inputSchema' => self::object_schema(
494 507 array(
495 - 'enabled' => array(
508 + 'enabled' => array(
496 509 'type' => 'boolean',
497 510 'description' => 'true installs the drop-in, false removes it.',
498 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 + ),
499 520 ),
500 521 array( 'enabled' )
501 522 ),
502 523 'write' => true,
@@ -561,9 +582,9 @@
561 582 'write' => false,
562 583 'handler' => array( self::class, 'list_commands' ),
563 584 ),
564 585 'run_command' => array(
565 - '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.',
566 587 'inputSchema' => self::object_schema(
567 588 array(
568 589 'command' => array(
569 590 'type' => 'string',
@@ -579,9 +600,9 @@
579 600 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.',
580 601 ),
581 602 'confirm_token' => array(
582 603 'type' => 'string',
583 - '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.',
584 605 ),
585 606 ),
586 607 array( 'command' )
587 608 ),
@@ -670,8 +691,49 @@
670 691 $catalog[ $name ] = $spec;
671 692 }
672 693 }
673 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 +
674 736 return $catalog;
675 737 }
676 738
677 739 /**
@@ -904,8 +966,11 @@
904 966 */
905 967 public static function list(): array {
906 968 $out = array();
907 969 foreach ( self::catalog() as $name => $spec ) {
970 + if ( ! empty( $spec['hidden'] ) ) {
971 + continue;
972 + }
908 973 $out[] = array(
909 974 'name' => $name,
910 975 'description' => $spec['description'],
911 976 'inputSchema' => $spec['inputSchema'],
@@ -944,8 +1009,39 @@
944 1009
945 1010 return $error;
946 1011 }
947 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 +
948 1044 // Scope enforcement: a read-only connection cannot invoke a tool that
949 1045 // mutates state. run_command is a gateway to the full CLI surface, so
950 1046 // it's treated as write regardless of the wrapped command. The active
951 1047 // credential's scope (pairing token OR OAuth access token) is carried
@@ -980,16 +1076,32 @@
980 1076 * covers each door at once — including any future tool that wraps the
981 1077 * same command. (#184)
982 1078 */
983 1079 $destructive = self::destructive_action( $name, $args );
984 - if ( '' !== $destructive ) {
985 - $confirmed = self::verify_clean_token( $args );
1080 + if ( ! empty( $destructive ) ) {
1081 + $confirmed = self::verify_confirm_token( $args, $name, $destructive );
986 1082 if ( is_wp_error( $confirmed ) ) {
987 - 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 );
988 1087 return $confirmed;
989 1088 }
990 1089 }
991 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 +
992 1104 self::$dispatching = true;
993 1105 try {
994 1106 $result = call_user_func( $catalog[ $name ]['handler'], $args );
995 1107
@@ -1066,8 +1178,13 @@
1066 1178
1067 1179 /**
1068 1180 * Cache status, stats, and detected server.
1069 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 + *
1070 1187 * @param array $args Unused.
1071 1188 * @return array
1072 1189 */
1073 1190 public static function get_cache_status( array $args ) {
@@ -1076,8 +1193,9 @@
1076 1193 return array(
1077 1194 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ),
1078 1195 'stats' => Cache::get_stats(),
1079 1196 'server' => Server::type(),
1197 + 'site_icon' => (string) get_site_icon_url( 64 ),
1080 1198 );
1081 1199 }
1082 1200
1083 1201 /**
@@ -1150,12 +1268,93 @@
1150 1268 'wp_version' => get_bloginfo( 'version' ),
1151 1269 'php_version' => PHP_VERSION,
1152 1270 'server' => Server::type(),
1153 1271 'multisite' => is_multisite(),
1272 + 'addons' => self::addon_licences(),
1154 1273 );
1155 1274 }
1156 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 +
1157 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 + /**
1158 1357 * All registered module descriptors.
1159 1358 *
1160 1359 * @param array $args Unused.
1161 1360 * @return array
@@ -1699,9 +1898,9 @@
1699 1898 * longer describes reality and clean_database refuses. That closes the
1700 1899 * window where a scan is shown to a human, something changes, and the
1701 1900 * delete removes more than was agreed to. (#184)
1702 1901 */
1703 - $result['confirm_token'] = self::mint_clean_token();
1902 + $result['confirm_token'] = self::mint_confirm_token( self::clean_fingerprint() );
1704 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' );
1705 1904
1706 1905 return $result;
1707 1906 }
@@ -1746,76 +1945,215 @@
1746 1945 );
1747 1946 }
1748 1947
1749 1948 /**
1750 - * 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.
1751 1951 *
1752 1952 * @param string $name Tool name.
1753 1953 * @param array $args Decoded tool arguments.
1754 - * @return string Canonical "<command> <action>", or '' when not destructive.
1954 + * @return array{name?:string,action?:string} The classified pair, or array() when not destructive.
1755 1955 */
1756 - private static function destructive_action( string $name, array $args ): string {
1956 + private static function destructive_action( string $name, array $args ): array {
1757 1957 // The gateway carries the real command in its arguments; a typed tool
1758 1958 // is identified by the command it is mapped to.
1759 1959 if ( 'run_command' === $name ) {
1760 1960 $command = isset( $args['command'] ) ? (string) $args['command'] : '';
1761 1961 if ( '' === $command ) {
1762 - return '';
1962 + return array();
1763 1963 }
1764 1964 $positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array();
1765 - $resolved = Cli_Bridge::classify( $command, $positional );
1766 - } elseif ( 'clean_database' === $name ) {
1767 - $resolved = Cli_Bridge::classify( 'db', array( 'clean' ) );
1768 - } 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'] ) ) {
1769 2033 return '';
1770 2034 }
2035 + return trim( $action['name'] . ' ' . ( $action['action'] ?? '' ) );
2036 + }
1771 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 {
1772 2045 if ( '' === $resolved['name'] ) {
1773 - return '';
2046 + return array();
1774 2047 }
1775 2048
1776 2049 $destructive = self::destructive_actions();
1777 2050 if ( ! isset( $destructive[ $resolved['name'] ] ) ) {
1778 - return '';
2051 + return array();
1779 2052 }
1780 2053 if ( ! in_array( $resolved['action'], (array) $destructive[ $resolved['name'] ], true ) ) {
1781 - return '';
2054 + return array();
1782 2055 }
1783 2056
1784 - return trim( $resolved['name'] . ' ' . $resolved['action'] );
2057 + return array(
2058 + 'name' => $resolved['name'],
2059 + 'action' => $resolved['action'],
2060 + );
1785 2061 }
1786 2062
1787 2063 /**
1788 - * Verify (and consume) the confirm_token minted by scan_database.
2064 + * Which CLI command(s) a generated tool name could have come from.
1789 2065 *
1790 - * @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().
1791 2126 * @return true|\WP_Error
1792 2127 */
1793 - private static function verify_clean_token( array $args ) {
2128 + private static function verify_confirm_token( array $args, string $tool, array $action ) {
1794 2129 $token = isset( $args['confirm_token'] ) ? (string) $args['confirm_token'] : '';
1795 2130 if ( '' === $token ) {
1796 - return new \WP_Error(
1797 - 'xspeed_mcp_confirm_required',
1798 - __( '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' ),
1799 - array( 'status' => 400 )
1800 - );
2131 + return self::confirm_required( $tool, $action );
1801 2132 }
1802 2133
1803 - // Single use: consumed whether or not the delete goes ahead, so one
1804 - // approval can never authorise a second, different deletion.
1805 - $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 );
1806 2142 if ( '' === $sealed ) {
1807 2143 return new \WP_Error(
1808 2144 'xspeed_mcp_confirm_invalid',
1809 - __( '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' ),
1810 2146 array( 'status' => 400 )
1811 2147 );
1812 2148 }
1813 2149
1814 - if ( ! hash_equals( $sealed, self::clean_fingerprint() ) ) {
2150 + if ( ! hash_equals( $sealed, $expected ) ) {
1815 2151 return new \WP_Error(
1816 2152 'xspeed_mcp_confirm_stale',
1817 - __( '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' ),
1818 2156 array( 'status' => 409 )
1819 2157 );
1820 2158 }
1821 2159
@@ -1821,18 +2159,160 @@
1821 2159
1822 2160 return true;
1823 2161 }
1824 2162
1825 - /** 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. */
1826 2299 private static function clean_fingerprint(): string {
1827 2300 return hash( 'sha256', (string) wp_json_encode( self::clean_scope() ) );
1828 2301 }
1829 2302
1830 2303 /** Lifetime of a confirm_token, from mint to refusal. */
1831 - private const CLEAN_TOKEN_TTL = 5 * MINUTE_IN_SECONDS;
2304 + private const CONFIRM_TOKEN_TTL = 5 * MINUTE_IN_SECONDS;
1832 2305
1833 - /** Storage key for a minted token (the token itself is never stored). */
1834 - 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 {
1835 2315 return 'xspeed_mcp_clean_' . hash( 'sha256', $token );
1836 2316 }
1837 2317
1838 2318 /*
@@ -1858,18 +2338,24 @@
1858 2338 * object cache does. Expiry is carried in the stored value and checked on
1859 2339 * read, since options have no TTL of their own.
1860 2340 */
1861 2341
1862 - 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 {
1863 2349 $token = wp_generate_password( 32, false );
1864 2350
1865 2351 // autoload=no: this is read once, by one request, minutes from now.
1866 2352 add_option(
1867 - self::clean_token_key( $token ),
2353 + self::confirm_token_key( $token ),
1868 2354 wp_json_encode(
1869 2355 array(
1870 - 'fingerprint' => self::clean_fingerprint(),
1871 - 'expires' => time() + self::CLEAN_TOKEN_TTL,
2356 + 'fingerprint' => $fingerprint,
2357 + 'expires' => time() + self::CONFIRM_TOKEN_TTL,
1872 2358 )
1873 2359 ),
1874 2360 '',
1875 2361 'no'
@@ -1874,9 +2360,9 @@
1874 2360 '',
1875 2361 'no'
1876 2362 );
1877 2363
1878 - self::purge_expired_clean_tokens();
2364 + self::purge_expired_confirm_tokens();
1879 2365
1880 2366 return $token;
1881 2367 }
1882 2368
@@ -1885,17 +2371,61 @@
1885 2371 *
1886 2372 * Consumes the record either way: a token is single use, so one approval
1887 2373 * can never authorise a second, different deletion.
1888 2374 */
1889 - private static function consume_clean_token( string $token ): string {
1890 - $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 );
1891 2379 $stored = get_option( $key );
1892 2380 if ( ! is_string( $stored ) || '' === $stored ) {
1893 2381 return '';
1894 2382 }
1895 2383
1896 - 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 + }
1897 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 +
1898 2428 $data = json_decode( $stored, true );
1899 2429 if ( ! is_array( $data ) || empty( $data['fingerprint'] ) ) {
1900 2430 return '';
1901 2431 }
@@ -1912,9 +2442,9 @@
1912 2442 * Options have no TTL, so an unused token would otherwise sit in
1913 2443 * wp_options forever — a scan that is never followed by a clean is the
1914 2444 * normal case, not the exception.
1915 2445 */
1916 - private static function purge_expired_clean_tokens(): void {
2446 + private static function purge_expired_confirm_tokens(): void {
1917 2447 global $wpdb;
1918 2448
1919 2449 if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
1920 2450 return;
@@ -2123,9 +2653,16 @@
2123 2653 $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
2124 2654 if ( null === $on ) {
2125 2655 return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
2126 2656 }
2127 - 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 );
2128 2665 }
2129 2666
2130 2667 /**
2131 2668 * List or clear stored Critical CSS.
@@ -2368,14 +2905,41 @@
2368 2905
2369 2906 /**
2370 2907 * Generate Critical CSS (Pro).
2371 2908 *
2372 - * @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.
2373 2915 * @return array|\WP_Error
2374 2916 */
2375 2917 public static function generate_critical_css( array $args ) {
2376 - unset( $args );
2377 - 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 );
2378 2942 }
2379 2943
2380 2944 /**
2381 2945 * Build a JSON Schema object node.