PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
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 +491 -48 1.3.7 → 1.4.1 View file →
@@ -144,9 +144,9 @@
144 144 'write' => false,
145 145 'handler' => array( self::class, 'list_modules' ),
146 146 ),
147 147 'get_site_info' => array(
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.',
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.',
149 149 'inputSchema' => self::object_schema( array(), array() ),
150 150 'write' => false,
151 151 'handler' => array( self::class, 'get_site_info' ),
152 152 ),
@@ -504,12 +504,20 @@
504 504 'toggle_object_cache' => array(
505 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.',
506 506 'inputSchema' => self::object_schema(
507 507 array(
508 - 'enabled' => array(
508 + 'enabled' => array(
509 509 'type' => 'boolean',
510 510 'description' => 'true installs the drop-in, false removes it.',
511 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 + ),
512 520 ),
513 521 array( 'enabled' )
514 522 ),
515 523 'write' => true,
@@ -574,9 +582,9 @@
574 582 'write' => false,
575 583 'handler' => array( self::class, 'list_commands' ),
576 584 ),
577 585 'run_command' => array(
578 - '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.',
579 587 'inputSchema' => self::object_schema(
580 588 array(
581 589 'command' => array(
582 590 'type' => 'string',
@@ -592,9 +600,9 @@
592 600 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.',
593 601 ),
594 602 'confirm_token' => array(
595 603 'type' => 'string',
596 - '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.',
597 605 ),
598 606 ),
599 607 array( 'command' )
600 608 ),
@@ -1068,16 +1076,32 @@
1068 1076 * covers each door at once — including any future tool that wraps the
1069 1077 * same command. (#184)
1070 1078 */
1071 1079 $destructive = self::destructive_action( $name, $args );
1072 - if ( '' !== $destructive ) {
1073 - $confirmed = self::verify_clean_token( $args );
1080 + if ( ! empty( $destructive ) ) {
1081 + $confirmed = self::verify_confirm_token( $args, $name, $destructive );
1074 1082 if ( is_wp_error( $confirmed ) ) {
1075 - 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 );
1076 1087 return $confirmed;
1077 1088 }
1078 1089 }
1079 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 +
1080 1104 self::$dispatching = true;
1081 1105 try {
1082 1106 $result = call_user_func( $catalog[ $name ]['handler'], $args );
1083 1107
@@ -1244,12 +1268,93 @@
1244 1268 'wp_version' => get_bloginfo( 'version' ),
1245 1269 'php_version' => PHP_VERSION,
1246 1270 'server' => Server::type(),
1247 1271 'multisite' => is_multisite(),
1272 + 'addons' => self::addon_licences(),
1248 1273 );
1249 1274 }
1250 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 +
1251 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 + /**
1252 1357 * All registered module descriptors.
1253 1358 *
1254 1359 * @param array $args Unused.
1255 1360 * @return array
@@ -1793,9 +1898,9 @@
1793 1898 * longer describes reality and clean_database refuses. That closes the
1794 1899 * window where a scan is shown to a human, something changes, and the
1795 1900 * delete removes more than was agreed to. (#184)
1796 1901 */
1797 - $result['confirm_token'] = self::mint_clean_token();
1902 + $result['confirm_token'] = self::mint_confirm_token( self::clean_fingerprint() );
1798 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' );
1799 1904
1800 1905 return $result;
1801 1906 }
@@ -1840,76 +1945,215 @@
1840 1945 );
1841 1946 }
1842 1947
1843 1948 /**
1844 - * 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.
1845 1951 *
1846 1952 * @param string $name Tool name.
1847 1953 * @param array $args Decoded tool arguments.
1848 - * @return string Canonical "<command> <action>", or '' when not destructive.
1954 + * @return array{name?:string,action?:string} The classified pair, or array() when not destructive.
1849 1955 */
1850 - private static function destructive_action( string $name, array $args ): string {
1956 + private static function destructive_action( string $name, array $args ): array {
1851 1957 // The gateway carries the real command in its arguments; a typed tool
1852 1958 // is identified by the command it is mapped to.
1853 1959 if ( 'run_command' === $name ) {
1854 1960 $command = isset( $args['command'] ) ? (string) $args['command'] : '';
1855 1961 if ( '' === $command ) {
1856 - return '';
1962 + return array();
1857 1963 }
1858 1964 $positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array();
1859 - $resolved = Cli_Bridge::classify( $command, $positional );
1860 - } elseif ( 'clean_database' === $name ) {
1861 - $resolved = Cli_Bridge::classify( 'db', array( 'clean' ) );
1862 - } 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'] ) ) {
1863 2033 return '';
1864 2034 }
2035 + return trim( $action['name'] . ' ' . ( $action['action'] ?? '' ) );
2036 + }
1865 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 {
1866 2045 if ( '' === $resolved['name'] ) {
1867 - return '';
2046 + return array();
1868 2047 }
1869 2048
1870 2049 $destructive = self::destructive_actions();
1871 2050 if ( ! isset( $destructive[ $resolved['name'] ] ) ) {
1872 - return '';
2051 + return array();
1873 2052 }
1874 2053 if ( ! in_array( $resolved['action'], (array) $destructive[ $resolved['name'] ], true ) ) {
1875 - return '';
2054 + return array();
1876 2055 }
1877 2056
1878 - return trim( $resolved['name'] . ' ' . $resolved['action'] );
2057 + return array(
2058 + 'name' => $resolved['name'],
2059 + 'action' => $resolved['action'],
2060 + );
1879 2061 }
1880 2062
1881 2063 /**
1882 - * Verify (and consume) the confirm_token minted by scan_database.
2064 + * Which CLI command(s) a generated tool name could have come from.
1883 2065 *
1884 - * @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().
1885 2126 * @return true|\WP_Error
1886 2127 */
1887 - private static function verify_clean_token( array $args ) {
2128 + private static function verify_confirm_token( array $args, string $tool, array $action ) {
1888 2129 $token = isset( $args['confirm_token'] ) ? (string) $args['confirm_token'] : '';
1889 2130 if ( '' === $token ) {
1890 - return new \WP_Error(
1891 - 'xspeed_mcp_confirm_required',
1892 - __( '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' ),
1893 - array( 'status' => 400 )
1894 - );
2131 + return self::confirm_required( $tool, $action );
1895 2132 }
1896 2133
1897 - // Single use: consumed whether or not the delete goes ahead, so one
1898 - // approval can never authorise a second, different deletion.
1899 - $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 );
1900 2142 if ( '' === $sealed ) {
1901 2143 return new \WP_Error(
1902 2144 'xspeed_mcp_confirm_invalid',
1903 - __( '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' ),
1904 2146 array( 'status' => 400 )
1905 2147 );
1906 2148 }
1907 2149
1908 - if ( ! hash_equals( $sealed, self::clean_fingerprint() ) ) {
2150 + if ( ! hash_equals( $sealed, $expected ) ) {
1909 2151 return new \WP_Error(
1910 2152 'xspeed_mcp_confirm_stale',
1911 - __( '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' ),
1912 2156 array( 'status' => 409 )
1913 2157 );
1914 2158 }
1915 2159
@@ -1915,18 +2159,160 @@
1915 2159
1916 2160 return true;
1917 2161 }
1918 2162
1919 - /** 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. */
1920 2299 private static function clean_fingerprint(): string {
1921 2300 return hash( 'sha256', (string) wp_json_encode( self::clean_scope() ) );
1922 2301 }
1923 2302
1924 2303 /** Lifetime of a confirm_token, from mint to refusal. */
1925 - private const CLEAN_TOKEN_TTL = 5 * MINUTE_IN_SECONDS;
2304 + private const CONFIRM_TOKEN_TTL = 5 * MINUTE_IN_SECONDS;
1926 2305
1927 - /** Storage key for a minted token (the token itself is never stored). */
1928 - 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 {
1929 2315 return 'xspeed_mcp_clean_' . hash( 'sha256', $token );
1930 2316 }
1931 2317
1932 2318 /*
@@ -1952,18 +2338,24 @@
1952 2338 * object cache does. Expiry is carried in the stored value and checked on
1953 2339 * read, since options have no TTL of their own.
1954 2340 */
1955 2341
1956 - 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 {
1957 2349 $token = wp_generate_password( 32, false );
1958 2350
1959 2351 // autoload=no: this is read once, by one request, minutes from now.
1960 2352 add_option(
1961 - self::clean_token_key( $token ),
2353 + self::confirm_token_key( $token ),
1962 2354 wp_json_encode(
1963 2355 array(
1964 - 'fingerprint' => self::clean_fingerprint(),
1965 - 'expires' => time() + self::CLEAN_TOKEN_TTL,
2356 + 'fingerprint' => $fingerprint,
2357 + 'expires' => time() + self::CONFIRM_TOKEN_TTL,
1966 2358 )
1967 2359 ),
1968 2360 '',
1969 2361 'no'
@@ -1968,9 +2360,9 @@
1968 2360 '',
1969 2361 'no'
1970 2362 );
1971 2363
1972 - self::purge_expired_clean_tokens();
2364 + self::purge_expired_confirm_tokens();
1973 2365
1974 2366 return $token;
1975 2367 }
1976 2368
@@ -1979,17 +2371,61 @@
1979 2371 *
1980 2372 * Consumes the record either way: a token is single use, so one approval
1981 2373 * can never authorise a second, different deletion.
1982 2374 */
1983 - private static function consume_clean_token( string $token ): string {
1984 - $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 );
1985 2379 $stored = get_option( $key );
1986 2380 if ( ! is_string( $stored ) || '' === $stored ) {
1987 2381 return '';
1988 2382 }
1989 2383
1990 - 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 + }
1991 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 +
1992 2428 $data = json_decode( $stored, true );
1993 2429 if ( ! is_array( $data ) || empty( $data['fingerprint'] ) ) {
1994 2430 return '';
1995 2431 }
@@ -2006,9 +2442,9 @@
2006 2442 * Options have no TTL, so an unused token would otherwise sit in
2007 2443 * wp_options forever — a scan that is never followed by a clean is the
2008 2444 * normal case, not the exception.
2009 2445 */
2010 - private static function purge_expired_clean_tokens(): void {
2446 + private static function purge_expired_confirm_tokens(): void {
2011 2447 global $wpdb;
2012 2448
2013 2449 if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
2014 2450 return;
@@ -2217,9 +2653,16 @@
2217 2653 $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
2218 2654 if ( null === $on ) {
2219 2655 return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
2220 2656 }
2221 - 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 );
2222 2665 }
2223 2666
2224 2667 /**
2225 2668 * List or clear stored Critical CSS.