PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 30 releases
← All changes | includes/modules/Mcp/Mcp_Tools.php +897 -47 1.1.41.3.4 View file →
@@ -29,10 +29,13 @@
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;
36 +use XSpeed\Tier_Registry;
37 +use XSpeed\Database_Cleaner;
35 38
36 39 defined( 'ABSPATH' ) || exit;
37 40
38 41 final class Mcp_Tools {
@@ -37,9 +40,9 @@
37 40
38 41 final class Mcp_Tools {
39 42
40 43 /** Valid cache purge types. */
41 - public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest' );
44 + public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest', 'cloudflare', 'cdn' );
42 45
43 46 /**
44 47 * Per-call read-only override. Null means "defer to the pairing token's
45 48 * scope" (the JSON-RPC path that predates OAuth). true/false is set by
@@ -135,8 +138,49 @@
135 138 'inputSchema' => self::object_schema( array(), array() ),
136 139 'write' => false,
137 140 'handler' => array( self::class, 'list_modules' ),
138 141 ),
142 + '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.',
144 + 'inputSchema' => self::object_schema( array(), array() ),
145 + 'write' => false,
146 + 'handler' => array( self::class, 'get_site_info' ),
147 + ),
148 + 'optimize_site' => array(
149 + '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.',
150 + 'inputSchema' => self::object_schema(
151 + array(
152 + 'aggressiveness' => array(
153 + 'type' => 'string',
154 + 'enum' => array( 'safe', 'standard', 'aggressive' ),
155 + 'description' => 'How far to go. Defaults to standard.',
156 + ),
157 + 'dry_run' => array(
158 + 'type' => 'boolean',
159 + 'description' => 'Return the plan without changing anything.',
160 + ),
161 + 'measure_score' => array(
162 + 'type' => 'string',
163 + 'enum' => array( 'auto', 'never', 'always' ),
164 + '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.',
165 + ),
166 + 'target_score' => array(
167 + 'type' => 'integer',
168 + 'minimum' => 1,
169 + 'maximum' => 100,
170 + '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.',
171 + ),
172 + 'max_rounds' => array(
173 + 'type' => 'integer',
174 + 'minimum' => 1,
175 + 'description' => 'Ceiling on rounds when target_score is set. Clamped to what the tuner allows.',
176 + ),
177 + ),
178 + array()
179 + ),
180 + 'write' => true,
181 + 'handler' => array( self::class, 'optimize_site' ),
182 + ),
139 183 'run_benchmark' => array(
140 184 'description' => 'Run a before/after cache benchmark on the home page and return the timings. Each side reports bytes (decoded payload) and bytes_transferred (compressed wire size).',
141 185 'inputSchema' => self::object_schema( array(), array() ),
142 186 'write' => false,
@@ -148,9 +192,9 @@
148 192 'write' => false,
149 193 'handler' => array( self::class, 'get_pro_audit' ),
150 194 ),
151 195 'purge_cache' => array(
152 - 'description' => 'Purge the site cache. "type" selects what to purge: all, page, assets, object, or rest. Defaults to all.',
196 + '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.',
153 197 'inputSchema' => self::object_schema(
154 198 array(
155 199 'type' => array(
156 200 'type' => 'string',
@@ -220,16 +264,24 @@
220 264 'write' => true,
221 265 'handler' => array( self::class, 'purge_cloudflare' ),
222 266 ),
223 267 'scan_database' => array(
224 - 'description' => 'Scan the database for bloat (post revisions, auto-drafts, trashed posts, spam comments, expired transients, orphaned meta) without deleting anything.',
268 + 'description' => 'Preview database bloat — post revisions, auto-drafts, trashed posts, spam comments, expired transients, orphaned meta — with a count per category. Deletes NOTHING. Also returns the confirm_token that clean_database requires, so this is always the first step before any deletion.',
225 269 'inputSchema' => self::object_schema( array(), array() ),
226 270 'write' => false,
227 271 'handler' => array( self::class, 'scan_database' ),
228 272 ),
229 273 'clean_database' => array(
230 - 'description' => 'Clean database bloat. Removes the categories currently enabled in the Database module settings. Destructive — run scan_database first to preview.',
231 - 'inputSchema' => self::object_schema( array(), array() ),
274 + 'description' => 'PERMANENTLY DELETE database bloat — post revisions, trashed posts, spam comments and the other categories enabled in the Database module settings. This is not a cache purge: it destroys real content and CANNOT be undone. Requires a confirm_token from scan_database, which shows the caller exactly what would be removed; the call is refused without one.',
275 + 'inputSchema' => self::object_schema(
276 + array(
277 + 'confirm_token' => array(
278 + 'type' => 'string',
279 + 'description' => 'The token returned by scan_database. Required — it proves the caller has seen what will be deleted. Expires after 5 minutes and is invalidated if the database changes.',
280 + ),
281 + ),
282 + array( 'confirm_token' )
283 + ),
232 284 'write' => true,
233 285 'handler' => array( self::class, 'clean_database' ),
234 286 ),
235 287 'flush_object_cache' => array(
@@ -243,10 +295,48 @@
243 295 'inputSchema' => self::object_schema( array(), array() ),
244 296 'write' => true,
245 297 'handler' => array( self::class, 'start_preloader' ),
246 298 ),
299 + 'run_score' => array(
300 + '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.',
301 + 'inputSchema' => self::object_schema(
302 + array(
303 + 'target' => array(
304 + 'type' => 'string',
305 + 'description' => 'URL to audit. Defaults to the configured URL, then the home page.',
306 + ),
307 + 'strategy' => array(
308 + 'type' => 'string',
309 + 'enum' => array( 'mobile', 'desktop' ),
310 + 'description' => 'mobile (default) or desktop. PageSpeed Insights only.',
311 + ),
312 + 'provider' => array(
313 + 'type' => 'string',
314 + 'description' => 'Audit provider, when the site has more than one configured.',
315 + ),
316 + 'force' => array(
317 + 'type' => 'boolean',
318 + 'description' => 'Re-run even when a recent cached result exists. Use after a change you want measured immediately.',
319 + ),
320 + ),
321 + array()
322 + ),
323 + // Classified `write`, deliberately. #147 asked whether a
324 + // read-only grant should be able to call this, since a run
325 + // changes no site CONFIGURATION. But it spends the site's own
326 + // metered PSI/GTmetrix quota and persists a Score_Store row,
327 + // and "read-only" should mean a call cannot cost the owner
328 + // anything. The gap this closes is that Free had NO typed
329 + // trigger at all: run_pagespeed is conditional on the Pro-only
330 + // `xspeed psi` command and silently drops off tools/list here,
331 + // leaving only run_command — also write, and a gateway to the
332 + // entire CLI surface. A write-scoped Hub connection now gets a
333 + // first-class trigger instead of the blunt instrument.
334 + 'write' => true,
335 + 'handler' => array( self::class, 'run_score' ),
336 + ),
247 337 'run_pagespeed' => array(
248 - '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.',
338 + '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.',
249 339 'inputSchema' => self::object_schema(
250 340 array(
251 341 'url' => array(
252 342 'type' => 'string',
@@ -260,12 +350,23 @@
260 350 'force' => array(
261 351 'type' => 'boolean',
262 352 'description' => 'Re-run even when a recent cached result exists. Use after a change you want measured immediately.',
263 353 ),
354 + 'provider' => array(
355 + 'type' => 'string',
356 + 'description' => 'Audit provider, when the site has more than one configured.',
357 + ),
264 358 ),
265 359 array()
266 360 ),
267 - 'write' => false,
361 + // `write`, matching run_score — the two dispatch to the same
362 + // `xspeed psi` and cost the owner the same metered quota, so
363 + // classifying them oppositely let a read-only grant make a real
364 + // outbound audit through this one while the other refused it.
365 + // Aligned toward write rather than read: generate_critical_css
366 + // sets the precedent that spending an external quota is a write
367 + // even when no site configuration changes. (QA B2 on #162)
368 + 'write' => true,
268 369 'handler' => array( self::class, 'run_pagespeed' ),
269 370 ),
270 371 'generate_critical_css' => array(
271 372 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.',
@@ -293,9 +394,9 @@
293 394 'write' => false,
294 395 'handler' => array( self::class, 'get_benchmark_history' ),
295 396 ),
296 397 'get_score_history' => array(
297 - 'description' => 'Stored EXTERNAL audit runs (PageSpeed Insights / GTmetrix): score, Core Web Vitals (LCP/FCP/CLS/TBT/SI/TTFB), which tool ran it, and the report link where one exists. Read-only — returns what this site already measured and never starts a new audit. Use run_pagespeed to actually run one.',
398 + 'description' => 'Stored EXTERNAL audit runs (PageSpeed Insights / GTmetrix): score, Core Web Vitals (LCP/FCP/CLS/TBT/SI/TTFB), which tool ran it, and the report link where one exists. Failed runs are included: ok is false and error says why, with score null. Never average or trend a run whose ok is false — it measured nothing. Read-only — returns what this site already measured and never starts a new audit. Use run_score to actually run one.',
298 399 'inputSchema' => self::object_schema(
299 400 array(
300 401 'limit' => array(
301 402 'type' => 'integer',
@@ -460,9 +561,9 @@
460 561 'write' => false,
461 562 'handler' => array( self::class, 'list_commands' ),
462 563 ),
463 564 'run_command' => array(
464 - '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("database clean"), run_command("psi", {}, {"url":"https://site.com","strategy":"mobile"}).',
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.',
465 566 'inputSchema' => self::object_schema(
466 567 array(
467 568 'command' => array(
468 569 'type' => 'string',
@@ -476,8 +577,12 @@
476 577 'options' => array(
477 578 'type' => 'object',
478 579 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.',
479 580 ),
581 + 'confirm_token' => array(
582 + '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.',
584 + ),
480 585 ),
481 586 array( 'command' )
482 587 ),
483 588 'write' => true,
@@ -508,19 +613,34 @@
508 613 'toggle_object_cache' => 'xspeed objcache',
509 614 'manage_critical_css' => 'xspeed ccss',
510 615 'get_preloader_status' => 'xspeed preloader',
511 616 'stop_preloader' => 'xspeed preloader',
512 - // These three have no module dependency — get_settings /
513 - // run_pagespeed / get_health are always present — but they belong
514 - // here so the generator skips their commands too. $conditional is
515 - // read for BOTH purposes: drop a tool when its command is gone,
516 - // and never generate an alias for a command a tool already covers.
517 - 'get_settings' => 'xspeed settings',
518 - 'update_settings' => 'xspeed settings',
519 - 'run_pagespeed' => 'xspeed psi',
520 - 'get_health' => 'xspeed health',
521 - 'get_score_history' => 'xspeed score',
522 617 );
618 +
619 + /*
620 + * Tools that are ALWAYS in the catalog, mapped to the command they
621 + * cover. These are listed separately from $conditional because the
622 + * two roles are different and used to be conflated in one map: this
623 + * set only tells the alias generator "don't emit an alias for this
624 + * command, a typed tool already covers it" — it must never drop a
625 + * tool.
626 + *
627 + * That conflation is exactly what broke get_settings/update_settings:
628 + * they were mapped to `xspeed settings`, a command that did not exist,
629 + * so the drop loop unset them on every request and they never reached
630 + * tools/list. `xspeed settings` now exists (SettingsModule), but the
631 + * split is what stops the class of bug recurring — an unconditional
632 + * tool can no longer be removed by a command going away. (#149/#153)
633 + */
634 + $always = array(
635 + 'get_settings' => 'xspeed settings',
636 + 'update_settings' => 'xspeed settings',
637 + 'run_pagespeed' => 'xspeed psi',
638 + 'get_health' => 'xspeed health',
639 + 'get_score_history' => 'xspeed score',
640 + 'purge_cache' => 'xspeed purge',
641 + );
642 +
523 643 $commands = Cli_Bridge::commands();
524 644 foreach ( $conditional as $tool => $command ) {
525 645 if ( ! isset( $commands[ $command ] ) ) {
526 646 unset( $catalog[ $tool ] );
@@ -525,8 +645,11 @@
525 645 if ( ! isset( $commands[ $command ] ) ) {
526 646 unset( $catalog[ $tool ] );
527 647 }
528 648 }
649 + // Alias generation reads both maps; the drop loop above reads only
650 + // $conditional.
651 + $conditional = array_merge( $conditional, $always );
529 652
530 653 /*
531 654 * One dedicated tool per xSpeed CLI command, generated from the same
532 655 * Cli_Bridge catalog the CLI registers from — so the AI can reach the
@@ -600,11 +723,20 @@
600 723 $required[] = $arg_name;
601 724 }
602 725 }
603 726
604 - $description = '' !== $spec['shortdesc']
605 - ? $spec['shortdesc']
606 - : sprintf( 'Run the "%s" xSpeed command.', $command );
727 + // Prefer the AI-facing hint. `shortdesc` is CLI help — written for
728 + // someone who already chose the command — so it says what the
729 + // output looks like, never when to reach for it. That is exactly
730 + // the question a model is answering when it reads tools/list, and
731 + // it is why 36 of these descriptions open with "Show". A module
732 + // that has not been given a hint yet keeps its shortdesc, so this
733 + // improves incrementally instead of needing all 40 at once. (#184)
734 + $description = '' !== ( $spec['ai_hint'] ?? '' )
735 + ? $spec['ai_hint']
736 + : ( '' !== $spec['shortdesc']
737 + ? $spec['shortdesc']
738 + : sprintf( 'Run the "%s" xSpeed command.', $command ) );
607 739
608 740 list( $write, $write_actions, $read_actions ) = self::cli_write_profile( $command, $spec['synopsis'] );
609 741
610 742 $tools[ $tool_name ] = array(
@@ -830,8 +962,34 @@
830 962 array( 'status' => 403 )
831 963 );
832 964 }
833 965
966 + /*
967 + * Scan-before-clean, enforced at the dispatcher rather than in one
968 + * handler.
969 + *
970 + * The guard used to live inside clean_database(). That protected a
971 + * TOOL NAME, not the action: run_command("db", ["clean"]) reaches the
972 + * same Cli_Bridge::run('db', ['clean']) with no token, no preview and
973 + * no warning, and list_commands advertises the route to the assistant
974 + * in plainer words ("Scan or clean WordPress bloat") than the tool
975 + * that just refused it. Measured on a live site, that second door
976 + * permanently destroyed 3,007 rows in a single call and reported
977 + * success.
978 + *
979 + * Every tool passes through invoke(), so a confirmation checked here
980 + * covers each door at once — including any future tool that wraps the
981 + * same command. (#184)
982 + */
983 + $destructive = self::destructive_action( $name, $args );
984 + if ( '' !== $destructive ) {
985 + $confirmed = self::verify_clean_token( $args );
986 + if ( is_wp_error( $confirmed ) ) {
987 + Mcp_Activity_Log::record( $name, $args, false, $confirmed->get_error_message(), 'write', self::$channel );
988 + return $confirmed;
989 + }
990 + }
991 +
834 992 self::$dispatching = true;
835 993 try {
836 994 $result = call_user_func( $catalog[ $name ]['handler'], $args );
837 995
@@ -922,8 +1080,82 @@
922 1080 );
923 1081 }
924 1082
925 1083 /**
1084 + * Facts about this site and install, stated explicitly.
1085 + *
1086 + * The Hub's fleet dashboard needed two things no tool reported directly.
1087 + * It had been INFERRING Pro's presence from `list_modules` — "any entry
1088 + * with tier: pro" — which works only because the registry returns just
1089 + * available modules. That is an inference riding an implementation
1090 + * detail, and it breaks the day a Pro install registers zero Pro modules.
1091 + *
1092 + * `pro_active` is "the Pro plugin is loaded and API-compatible";
1093 + * `licensed` is a separate question, since Pro can be active but
1094 + * unlicensed (its modules then boot but their settings are locked). Both
1095 + * are reported so a consumer never has to guess which one it wanted.
1096 + * (#146)
1097 + *
1098 + * @param array $args Unused.
1099 + * @return array
1100 + */
1101 + /**
1102 + * Is Pro licensed right now?
1103 + *
1104 + * Resolved through the `xspeed_module_descriptor` filter — the one Pro
1105 + * actually registers — by running a minimal Pro descriptor through it and
1106 + * reading back the `locked` flag Pro sets when the licence is inactive.
1107 + *
1108 + * `license` is deliberately not used as the probe slug: Pro exempts that
1109 + * module from locking so an expired site can still reach the screen where
1110 + * a new key is entered, so it would always come back unlocked.
1111 + */
1112 + private static function pro_licensed(): bool {
1113 + $probe = apply_filters(
1114 + 'xspeed_module_descriptor',
1115 + array(
1116 + 'slug' => '__license_probe__',
1117 + 'tier' => 'pro',
1118 + ),
1119 + null
1120 + );
1121 +
1122 + return empty( $probe['locked'] );
1123 + }
1124 +
1125 + public static function get_site_info( array $args ) {
1126 + unset( $args );
1127 +
1128 + $pro_active = Tier_Registry::pro_active();
1129 +
1130 + return array(
1131 + 'pro_active' => $pro_active,
1132 + 'pro_version' => defined( 'XSPEED_PRO_VERSION' ) ? (string) constant( 'XSPEED_PRO_VERSION' ) : null,
1133 + // Distinct from pro_active: Pro can be installed and running
1134 + // while its license is expired or absent.
1135 + //
1136 + // NOT `apply_filters( 'xspeed_pro_licensed', true )`. That hook is
1137 + // only ever APPLIED by Pro as an override point — no released
1138 + // version registers it — so with nothing listening the `true`
1139 + // default stood and this reported `licensed: true` on a fully
1140 + // revoked licence: the exact misreport the tool exists to
1141 + // eliminate. (QA blocker on #158)
1142 + //
1143 + // Ask the question the dashboard asks instead. Pro DOES register
1144 + // `xspeed_module_descriptor` and stamps `locked => 'license'` on
1145 + // every Pro entry when the licence is inactive, so reading that
1146 + // back is a real signal, and it cannot drift from what the panel
1147 + // shows because it IS what the panel shows.
1148 + 'licensed' => $pro_active ? self::pro_licensed() : false,
1149 + 'plugin_version' => defined( 'XSPEED_VERSION' ) ? (string) constant( 'XSPEED_VERSION' ) : null,
1150 + 'wp_version' => get_bloginfo( 'version' ),
1151 + 'php_version' => PHP_VERSION,
1152 + 'server' => Server::type(),
1153 + 'multisite' => is_multisite(),
1154 + );
1155 + }
1156 +
1157 + /**
926 1158 * All registered module descriptors.
927 1159 *
928 1160 * @param array $args Unused.
929 1161 * @return array
@@ -933,8 +1165,42 @@
933 1165 return Admin::modules_payload();
934 1166 }
935 1167
936 1168 /**
1169 + * Run the optimization autopilot.
1170 + *
1171 + * A thin wrapper: everything — the plan, the verification, the revert —
1172 + * lives in Optimize_Runner, so the CLI and this tool cannot drift into
1173 + * making different decisions about the same site.
1174 + *
1175 + * @param array<string,mixed> $args Tool arguments.
1176 + * @return array<string,mixed>|\WP_Error
1177 + */
1178 + public static function optimize_site( array $args = array() ) {
1179 + $run = array(
1180 + 'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ),
1181 + 'dry_run' => (bool) ( $args['dry_run'] ?? false ),
1182 + 'measure_score' => (string) ( $args['measure_score'] ?? 'auto' ),
1183 + );
1184 +
1185 + // Tuning arguments are forwarded ONLY when present, and are not
1186 + // understood by Free — a listener on `xspeed_optimize_report` reads
1187 + // them from that filter's `$context`. Passing them through rather
1188 + // than naming them in the array above is deliberate: this handler
1189 + // builds an explicit whitelist, so an argument it does not list is
1190 + // silently dropped. A caller asking to reach a score would have got a
1191 + // single pass and a success response — wrong behaviour with no error,
1192 + // which is the expensive kind to diagnose.
1193 + foreach ( array( 'target_score', 'max_rounds' ) as $key ) {
1194 + if ( isset( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) {
1195 + $run[ $key ] = (int) $args[ $key ];
1196 + }
1197 + }
1198 +
1199 + return \XSpeed\Optimize_Runner::run( $run );
1200 + }
1201 +
1202 + /**
937 1203 * Before/after cache benchmark timings.
938 1204 *
939 1205 * @param array $args Unused.
940 1206 * @return array
@@ -979,12 +1245,43 @@
979 1245 }
980 1246 // Named source, not the default "manual": the purge log's whole job
981 1247 // is to let an admin see that the cache cleared because an assistant
982 1248 // asked, not because someone clicked.
983 - $count = Cache::purge_type( $type, __( 'AI assistant', 'xspeed' ) );
1249 + $cause = __( 'AI assistant', 'xspeed' );
1250 +
1251 + /*
1252 + * `page`, `assets` and `rest` are fine-grained slices of the local
1253 + * sweep with no target of their own, and they predate this tool's
1254 + * per-store report — an assistant asking for `page` means the HTML,
1255 + * not the HTML plus the minified bundles plus every purge listener.
1256 + * They stay on purge_type() so their meaning does not change under
1257 + * callers already relying on it.
1258 + *
1259 + * Everything else routes through the runner — the same core function
1260 + * the CLI and the REST callback use — so an assistant told "cache
1261 + * cleared" is reading the same per-store verdict a human would get,
1262 + * including a Cloudflare zone that refused the purge.
1263 + */
1264 + if ( in_array( $type, array( 'page', 'assets', 'rest' ), true ) ) {
1265 + return array(
1266 + 'purged' => $type,
1267 + 'count' => Cache::purge_type( $type, $cause ),
1268 + 'ok' => true,
1269 + 'stats' => Cache::get_stats(),
1270 + );
1271 + }
1272 +
1273 + $report = \XSpeed\Purge_Runner::run( array( $type ), $cause );
1274 + $count = 0;
1275 + foreach ( $report['types'] as $row ) {
1276 + $count += (int) $row['entries'];
1277 + }
1278 +
984 1279 return array(
985 1280 'purged' => $type,
986 1281 'count' => $count,
1282 + 'ok' => $report['ok'],
1283 + 'report' => $report['types'],
987 1284 'stats' => Cache::get_stats(),
988 1285 );
989 1286 }
990 1287
@@ -1007,18 +1304,68 @@
1007 1304
1008 1305 // Persist cache_enabled the same way the Free /cache/toggle route
1009 1306 // does (class-rest-api.php:235) — Cache::toggle handles the drop-in
1010 1307 // + wp-config; Settings owns the option flag.
1011 - Settings::update( array( 'cache_enabled' => $enabled ) );
1308 + //
1309 + // From the RESULT, not from $enabled: toggle() refuses to enable when
1310 + // another caching plugin owns the drop-in, and writing the requested
1311 + // value regardless left the site reporting a cache it had not
1312 + // installed — over MCP, with no human reading the response.
1012 1313
1013 1314 return array(
1014 - 'cache_enabled' => $enabled,
1015 - 'install_state' => $install,
1016 - 'stats' => Cache::get_stats(),
1315 + 'cache_enabled' => $install['enabled'],
1316 + 'blocked' => ! empty( $install['blocked'] ),
1317 + 'blocked_reason' => $install['blocked_reason'] ?? null,
1318 + 'install_state' => $install,
1319 + 'stats' => Cache::get_stats(),
1017 1320 );
1018 1321 }
1019 1322
1020 1323 /**
1324 + * Is this module reachable over MCP right now?
1325 + *
1326 + * Mirrors SettingsModule::module_reachable(). Registration is not
1327 + * enough: Module_Registry::available() only asks whether Pro is LOADED,
1328 + * not whether it is LICENSED, so an unlicensed Pro site had every Pro
1329 + * module readable and writable over MCP while the dashboard showed it
1330 + * locked — reachable by any agent holding a write token. (QA M2)
1331 + *
1332 + * The licence answer comes through the `xspeed_module_descriptor` filter
1333 + * Pro registers, so Free never names a Pro class. (NOT
1334 + * `xspeed_pro_licensed` — Pro only ever APPLIES that one as an override
1335 + * and nothing listens to it, so gating on it silently passed everything.)
1336 + * `license` is exempt for the same reason Pro exempts it: locking it
1337 + * would remove the only surface that can fix an expired licence.
1338 + */
1339 + private static function settings_module_reachable( string $slug ): bool {
1340 + $module = \XSpeed\Module_Registry::available()[ $slug ] ?? null;
1341 + if ( ! $module ) {
1342 + return false;
1343 + }
1344 + if ( \XSpeed\Module::TIER_PRO !== $module->tier() || 'license' === $slug ) {
1345 + return true;
1346 + }
1347 +
1348 + // Ask the SAME question the dashboard asks. `xspeed_pro_licensed` is
1349 + // only ever APPLIED by Pro as an override hook — nothing registers it
1350 + // — so calling it here returned the default `true` and gated nothing.
1351 + // Pro DOES register `xspeed_module_descriptor`, and sets
1352 + // `locked => 'license'` on every Pro entry when the licence is
1353 + // inactive. Reusing that keeps one definition of "locked" instead of
1354 + // a second one in Free that can drift from the panel. (QA M2)
1355 + $entry = apply_filters(
1356 + 'xspeed_module_descriptor',
1357 + array(
1358 + 'slug' => $slug,
1359 + 'tier' => $module->tier(),
1360 + ),
1361 + $module
1362 + );
1363 +
1364 + return empty( $entry['locked'] );
1365 + }
1366 +
1367 + /**
1021 1368 * Read a module's schema-validated settings.
1022 1369 *
1023 1370 * @param array $args { module:string }.
1024 1371 * @return array|\WP_Error
@@ -1031,13 +1378,43 @@
1031 1378 __( 'The "module" parameter is required.', 'xspeed' ),
1032 1379 array( 'status' => 400 )
1033 1380 );
1034 1381 }
1035 - return array(
1036 - 'module' => $module,
1037 - // Public view — secret fields masked. An MCP agent must never be able
1038 - // to read stored credentials back in plaintext. (#115)
1039 - 'settings' => Settings_Manager::get_public( $module ),
1382 + if ( ! self::settings_module_reachable( $module ) ) {
1383 + return new \WP_Error(
1384 + 'xspeed_mcp_unknown_module',
1385 + sprintf(
1386 + /* translators: %s: module slug. */
1387 + __( 'Unknown module "%s".', 'xspeed' ),
1388 + $module
1389 + ),
1390 + array( 'status' => 404 )
1391 + );
1392 + }
1393 + /**
1394 + * Filter the get_settings MCP payload for one module.
1395 + *
1396 + * Lets the module that owns the settings attach state the stored
1397 + * values alone cannot express — a toggle that is on but resolves to
1398 + * no effect on this host (Brotli without ngx_brotli), a configured
1399 + * generator that has never succeeded. Free never names Pro classes,
1400 + * so this seam is how a Pro module reaches the response an agent
1401 + * reads.
1402 + *
1403 + * @param array<string,mixed> $payload The response: module + settings.
1404 + * @param string $module Module slug.
1405 + * @param string $action 'get' here; 'update' on writes.
1406 + */
1407 + return apply_filters(
1408 + 'xspeed_mcp_settings_payload',
1409 + array(
1410 + 'module' => $module,
1411 + // Public view — secret fields masked. An MCP agent must never be able
1412 + // to read stored credentials back in plaintext. (#115)
1413 + 'settings' => Settings_Manager::get_public( $module ),
1414 + ),
1415 + $module,
1416 + 'get'
1040 1417 );
1041 1418 }
1042 1419
1043 1420 /**
@@ -1062,8 +1439,19 @@
1062 1439 __( 'The "values" parameter must be an object of setting keys.', 'xspeed' ),
1063 1440 array( 'status' => 400 )
1064 1441 );
1065 1442 }
1443 + if ( ! self::settings_module_reachable( $module ) ) {
1444 + return new \WP_Error(
1445 + 'xspeed_mcp_unknown_module',
1446 + sprintf(
1447 + /* translators: %s: module slug. */
1448 + __( 'Unknown module "%s".', 'xspeed' ),
1449 + $module
1450 + ),
1451 + array( 'status' => 404 )
1452 + );
1453 + }
1066 1454 // Writing credentials over MCP requires the explicit `configure` grant —
1067 1455 // off by default even for a write-scoped connection — so an agent can't
1068 1456 // silently repoint the Cloudflare/object-cache backend at an attacker
1069 1457 // endpoint. Refuse with a message naming exactly which fields need it.
@@ -1086,17 +1474,162 @@
1086 1474 )
1087 1475 );
1088 1476 }
1089 1477 }
1090 - return array(
1091 - 'module' => $module,
1092 - // Return value is already masked (Settings_Manager::update returns the
1093 - // public view), so a written secret isn't echoed back either. (#115)
1094 - 'settings' => Settings_Manager::update( $module, $values ),
1478 + // The Pro licence WRITE gate. `settings_module_reachable()` above already
1479 + // hides locked Pro modules, but that is a VISIBILITY check answered by
1480 + // the `xspeed_module_descriptor` filter — a different question from "may
1481 + // this be written", and one that drifts the moment Pro changes how it
1482 + // flags `locked`. Ask the write gate itself, the same one REST consults
1483 + // via Module::update_settings(), so the two can't disagree.
1484 + //
1485 + // This is not theoretical: with the descriptor's `locked` flag removed,
1486 + // this handler wrote `enabled: false -> true` to a module whose
1487 + // is_license_locked() was true, because it persists through
1488 + // Settings_Manager::update() and never reaches Module::update_settings().
1489 + // (#185)
1490 + $module_object = \XSpeed\Module_Registry::get( $module );
1491 + if ( $module_object && $module_object->is_license_locked() ) {
1492 + // Match the REST path's audit trail — a refused write is a security
1493 + // event and must be visible in the activity log wherever it came
1494 + // from. Module::license_write_refusal() records the same type.
1495 + \XSpeed\Activity_Log::record(
1496 + 'license_write_refused',
1497 + sprintf(
1498 + /* translators: %s: module slug. */
1499 + __( 'Refused an MCP settings write to the Pro module "%s" — no valid license.', 'xspeed' ),
1500 + $module
1501 + ),
1502 + \XSpeed\Activity_Log::WARN
1503 + );
1504 +
1505 + return new \WP_Error(
1506 + 'xspeed_license_required',
1507 + sprintf(
1508 + /* translators: %s: module slug. */
1509 + __( '"%s" is a Pro module and this site has no active license, so the write was refused. Nothing was changed.', 'xspeed' ),
1510 + $module
1511 + ),
1512 + array(
1513 + 'status' => 403,
1514 + 'module' => $module,
1515 + )
1516 + );
1517 + }
1518 +
1519 + // An agent cannot tell a silent no-op from a real write, so refuse
1520 + // instead of returning a success payload. update() walks the schema:
1521 + // an out-of-schema key is never written and never mentioned, and an
1522 + // in-schema key with a rejected value quietly keeps the stored one.
1523 + // The realistic case is `cache_enabled` on the `cache` module — the
1524 + // most natural way to ask for caching, and a complete no-op. (#206)
1525 + $report = self::inspect_or_error( $module, $values );
1526 + if ( is_wp_error( $report ) ) {
1527 + return $report;
1528 + }
1529 +
1530 + /**
1531 + * Filter the update_settings MCP payload for one module.
1532 + *
1533 + * The write path's twin of the get filter above — this is where a
1534 + * module can say "stored, but inert on this host" in the same
1535 + * response that reports the write, instead of returning a plain
1536 + * success an agent relays as "enabled". Documented in
1537 + * docs/guides/hooks-and-filters.md.
1538 + *
1539 + * @param array<string,mixed> $payload The response: module + settings.
1540 + * @param string $module Module slug.
1541 + * @param string $action 'update' here; 'get' on reads.
1542 + */
1543 + return apply_filters(
1544 + 'xspeed_mcp_settings_payload',
1545 + array(
1546 + 'module' => $module,
1547 + // Return value is already masked (Settings_Manager::update returns the
1548 + // public view), so a written secret isn't echoed back either. (#115)
1549 + 'settings' => Settings_Manager::update( $module, $values ),
1550 + ),
1551 + $module,
1552 + 'update'
1095 1553 );
1096 1554 }
1097 1555
1098 1556 /**
1557 + * Refuse a settings payload carrying keys that would be silently dropped.
1558 + *
1559 + * @param string $module Module slug.
1560 + * @param array<string,mixed> $values Proposed values.
1561 + * @return true|\WP_Error True when every key would be applied.
1562 + */
1563 + private static function inspect_or_error( string $module, array $values ) {
1564 + $report = Settings_Manager::inspect_input( $module, $values );
1565 + if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) && empty( $report['locked'] ) ) {
1566 + return true;
1567 + }
1568 +
1569 + $parts = array();
1570 + // A field pinned by a wp-config.php constant cannot be written. Say so
1571 + // rather than returning a success the agent relays as "changed" over a
1572 + // write that update() would silently drop. (#398)
1573 + foreach ( $report['locked'] as $key ) {
1574 + $spec = ( Module_Registry::get( $module ) ? Module_Registry::get( $module )->settings_schema()[ $key ] ?? array() : array() );
1575 + $constant = Settings_Manager::effective_constant( $module, $key, is_array( $spec ) ? $spec : array() );
1576 + $parts[] = sprintf(
1577 + /* translators: 1: setting key, 2: wp-config.php constant name. */
1578 + __( '"%1$s" is defined in wp-config.php as %2$s and cannot be changed here', 'xspeed' ),
1579 + $key,
1580 + (string) $constant
1581 + );
1582 + }
1583 + foreach ( $report['unknown'] as $key ) {
1584 + $detail = sprintf(
1585 + /* translators: 1: setting key, 2: module slug. */
1586 + __( '"%1$s" is not a setting of module "%2$s"', 'xspeed' ),
1587 + $key,
1588 + $module
1589 + );
1590 + $hint = Settings_Manager::hint_for_unknown_key( $key );
1591 + if ( '' !== $hint ) {
1592 + $detail .= ' — ' . $hint;
1593 + } else {
1594 + $near = Settings_Manager::did_you_mean( $module, $key );
1595 + if ( ! empty( $near ) ) {
1596 + $detail .= sprintf(
1597 + /* translators: %s: comma-separated setting names. */
1598 + __( ' — did you mean: %s?', 'xspeed' ),
1599 + implode( ', ', $near )
1600 + );
1601 + }
1602 + }
1603 + $parts[] = $detail;
1604 + }
1605 + foreach ( $report['invalid'] as $key ) {
1606 + $parts[] = sprintf(
1607 + /* translators: %s: setting key. */
1608 + __( '"%s" was rejected by the schema (wrong type, or outside the allowed range/options)', 'xspeed' ),
1609 + $key
1610 + );
1611 + }
1612 +
1613 + return new \WP_Error(
1614 + 'xspeed_settings_refused',
1615 + sprintf(
1616 + /* translators: 1: module slug, 2: reasons. */
1617 + __( 'Refused to update %1$s — nothing was written. %2$s', 'xspeed' ),
1618 + $module,
1619 + implode( '; ', $parts )
1620 + ),
1621 + array(
1622 + 'status' => 400,
1623 + 'refused_unknown' => $report['unknown'],
1624 + 'refused_invalid' => $report['invalid'],
1625 + 'refused_locked' => $report['locked'],
1626 + 'would_apply' => $report['applied'],
1627 + )
1628 + );
1629 + }
1630 +
1631 + /**
1099 1632 * List every command run_command can invoke (the full CLI surface).
1100 1633 *
1101 1634 * @param array $args Unused.
1102 1635 * @return array
@@ -1150,12 +1683,261 @@
1150 1683 * @return array|\WP_Error
1151 1684 */
1152 1685 public static function scan_database( array $args ) {
1153 1686 unset( $args );
1154 - return Cli_Bridge::run( 'db', array( 'scan' ) );
1687 + $result = Cli_Bridge::run( 'db', array( 'scan' ) );
1688 + if ( is_wp_error( $result ) || empty( $result['ok'] ) ) {
1689 + return $result;
1690 + }
1691 +
1692 + /*
1693 + * Mint the token clean_database will demand, and state what it covers.
1694 + *
1695 + * The scan is the only place the caller can see what is about to be
1696 + * destroyed, so it is the only honest place to authorise the delete.
1697 + * The token is bound to the CATEGORIES ENABLED and the COUNTS FOUND at
1698 + * this moment: if either moves before the delete lands, the token no
1699 + * longer describes reality and clean_database refuses. That closes the
1700 + * window where a scan is shown to a human, something changes, and the
1701 + * delete removes more than was agreed to. (#184)
1702 + */
1703 + $result['confirm_token'] = self::mint_clean_token();
1704 + $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 +
1706 + return $result;
1155 1707 }
1156 1708
1709 + /** Categories currently enabled for deletion, with what a scan found in each. */
1710 + private static function clean_scope(): array {
1711 + $enabled = array_keys( array_filter( Settings_Manager::get( 'database' ), static fn( $v ) => true === $v ) );
1712 + sort( $enabled );
1713 +
1714 + $counts = array();
1715 + foreach ( Database_Cleaner::scan() as $key => $row ) {
1716 + $counts[ $key ] = is_array( $row ) ? (int) ( $row['count'] ?? 0 ) : (int) $row;
1717 + }
1718 + ksort( $counts );
1719 +
1720 + return array(
1721 + 'enabled' => $enabled,
1722 + 'counts' => $counts,
1723 + );
1724 + }
1725 +
1157 1726 /**
1727 + * Actions that permanently destroy content and therefore require a
1728 + * confirm_token, keyed by canonical command name.
1729 + *
1730 + * Keyed by ACTION, not by tool name, because the same action is
1731 + * reachable through several tools (the typed clean_database, the
1732 + * run_command gateway, and any future wrapper).
1733 + *
1734 + * @return array<string, string[]>
1735 + */
1736 + private static function destructive_actions(): array {
1737 + /**
1738 + * Filter the command actions that require an explicit confirmation.
1739 + *
1740 + * @since 1.1.6
1741 + * @param array<string, string[]> $actions Action names keyed by command.
1742 + */
1743 + return (array) apply_filters(
1744 + 'xspeed_mcp_destructive_actions',
1745 + array( 'xspeed db' => array( 'clean' ) )
1746 + );
1747 + }
1748 +
1749 + /**
1750 + * Name the destructive action a call would run, or '' if it is harmless.
1751 + *
1752 + * @param string $name Tool name.
1753 + * @param array $args Decoded tool arguments.
1754 + * @return string Canonical "<command> <action>", or '' when not destructive.
1755 + */
1756 + private static function destructive_action( string $name, array $args ): string {
1757 + // The gateway carries the real command in its arguments; a typed tool
1758 + // is identified by the command it is mapped to.
1759 + if ( 'run_command' === $name ) {
1760 + $command = isset( $args['command'] ) ? (string) $args['command'] : '';
1761 + if ( '' === $command ) {
1762 + return '';
1763 + }
1764 + $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 {
1769 + return '';
1770 + }
1771 +
1772 + if ( '' === $resolved['name'] ) {
1773 + return '';
1774 + }
1775 +
1776 + $destructive = self::destructive_actions();
1777 + if ( ! isset( $destructive[ $resolved['name'] ] ) ) {
1778 + return '';
1779 + }
1780 + if ( ! in_array( $resolved['action'], (array) $destructive[ $resolved['name'] ], true ) ) {
1781 + return '';
1782 + }
1783 +
1784 + return trim( $resolved['name'] . ' ' . $resolved['action'] );
1785 + }
1786 +
1787 + /**
1788 + * Verify (and consume) the confirm_token minted by scan_database.
1789 + *
1790 + * @param array $args Decoded tool arguments.
1791 + * @return true|\WP_Error
1792 + */
1793 + private static function verify_clean_token( array $args ) {
1794 + $token = isset( $args['confirm_token'] ) ? (string) $args['confirm_token'] : '';
1795 + 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 + );
1801 + }
1802 +
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 );
1806 + if ( '' === $sealed ) {
1807 + return new \WP_Error(
1808 + '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' ),
1810 + array( 'status' => 400 )
1811 + );
1812 + }
1813 +
1814 + if ( ! hash_equals( $sealed, self::clean_fingerprint() ) ) {
1815 + return new \WP_Error(
1816 + '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' ),
1818 + array( 'status' => 409 )
1819 + );
1820 + }
1821 +
1822 + return true;
1823 + }
1824 +
1825 + /** Fingerprint of the scope, so a token cannot outlive what it described. */
1826 + private static function clean_fingerprint(): string {
1827 + return hash( 'sha256', (string) wp_json_encode( self::clean_scope() ) );
1828 + }
1829 +
1830 + /** Lifetime of a confirm_token, from mint to refusal. */
1831 + private const CLEAN_TOKEN_TTL = 5 * MINUTE_IN_SECONDS;
1832 +
1833 + /** Storage key for a minted token (the token itself is never stored). */
1834 + private static function clean_token_key( string $token ): string {
1835 + return 'xspeed_mcp_clean_' . hash( 'sha256', $token );
1836 + }
1837 +
1838 + /*
1839 + * The token is held in an OPTION, not a transient.
1840 + *
1841 + * scan_database and clean_database are two separate HTTP requests, so the
1842 + * token has to survive between them. With an external object cache
1843 + * installed, set_transient() writes to that cache ONLY and never touches
1844 + * the options table — so on any site whose object cache is
1845 + * non-persistent, flushed between requests, or simply orphaned (a stale
1846 + * W3TC/Redis drop-in pointing at a dead backend), the token evaporates the
1847 + * moment it is minted.
1848 + *
1849 + * That does not fail safe. It makes the confirmation UNSATISFIABLE:
1850 + * clean_database can never be authorised by any sequence of calls, and the
1851 + * operator's only remaining route to the feature is the admin panel. A
1852 + * guard that cannot be passed is a broken feature, and the pressure it
1853 + * creates is to remove the guard. Reproduced on a stack running W3 Total
1854 + * Cache's object-cache drop-in: every freshly minted token was refused as
1855 + * "unknown or expired" on the very next request. (#184)
1856 + *
1857 + * Options are backed by the database, so the token persists whatever the
1858 + * object cache does. Expiry is carried in the stored value and checked on
1859 + * read, since options have no TTL of their own.
1860 + */
1861 +
1862 + private static function mint_clean_token(): string {
1863 + $token = wp_generate_password( 32, false );
1864 +
1865 + // autoload=no: this is read once, by one request, minutes from now.
1866 + add_option(
1867 + self::clean_token_key( $token ),
1868 + wp_json_encode(
1869 + array(
1870 + 'fingerprint' => self::clean_fingerprint(),
1871 + 'expires' => time() + self::CLEAN_TOKEN_TTL,
1872 + )
1873 + ),
1874 + '',
1875 + 'no'
1876 + );
1877 +
1878 + self::purge_expired_clean_tokens();
1879 +
1880 + return $token;
1881 + }
1882 +
1883 + /**
1884 + * Read a minted token's sealed fingerprint, or '' if unknown/expired.
1885 + *
1886 + * Consumes the record either way: a token is single use, so one approval
1887 + * can never authorise a second, different deletion.
1888 + */
1889 + private static function consume_clean_token( string $token ): string {
1890 + $key = self::clean_token_key( $token );
1891 + $stored = get_option( $key );
1892 + if ( ! is_string( $stored ) || '' === $stored ) {
1893 + return '';
1894 + }
1895 +
1896 + delete_option( $key );
1897 +
1898 + $data = json_decode( $stored, true );
1899 + if ( ! is_array( $data ) || empty( $data['fingerprint'] ) ) {
1900 + return '';
1901 + }
1902 + if ( ! isset( $data['expires'] ) || time() > (int) $data['expires'] ) {
1903 + return '';
1904 + }
1905 +
1906 + return (string) $data['fingerprint'];
1907 + }
1908 +
1909 + /**
1910 + * Drop token rows nobody consumed.
1911 + *
1912 + * Options have no TTL, so an unused token would otherwise sit in
1913 + * wp_options forever — a scan that is never followed by a clean is the
1914 + * normal case, not the exception.
1915 + */
1916 + private static function purge_expired_clean_tokens(): void {
1917 + global $wpdb;
1918 +
1919 + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
1920 + return;
1921 + }
1922 +
1923 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- no options API for "select by key prefix"; runs only when a token is minted.
1924 + $names = $wpdb->get_col(
1925 + $wpdb->prepare(
1926 + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
1927 + $wpdb->esc_like( 'xspeed_mcp_clean_' ) . '%'
1928 + )
1929 + );
1930 +
1931 + foreach ( (array) $names as $name ) {
1932 + $data = json_decode( (string) get_option( $name ), true );
1933 + if ( ! is_array( $data ) || ! isset( $data['expires'] ) || time() > (int) $data['expires'] ) {
1934 + delete_option( $name );
1935 + }
1936 + }
1937 + }
1938 +
1939 + /**
1158 1940 * Clean database bloat (destructive).
1159 1941 *
1160 1942 * @param array $args Unused.
1161 1943 * @return array|\WP_Error
@@ -1160,8 +1942,17 @@
1160 1942 * @param array $args Unused.
1161 1943 * @return array|\WP_Error
1162 1944 */
1163 1945 public static function clean_database( array $args ) {
1946 + /*
1947 + * The scan-before-clean confirmation is enforced in invoke(), which
1948 + * every tool passes through — see destructive_action(). It is NOT
1949 + * repeated here: the token is single-use, so checking it twice would
1950 + * consume it on the first check and reject the caller on the second.
1951 + *
1952 + * Reaching this line means the dispatcher already verified a token
1953 + * bound to a scan of the current database state. (#184)
1954 + */
1164 1955 unset( $args );
1165 1956 return Cli_Bridge::run( 'db', array( 'clean' ) );
1166 1957 }
1167 1958
@@ -1389,10 +2180,12 @@
1389 2180
1390 2181 $limit = isset( $args['limit'] ) ? (int) $args['limit'] : 100;
1391 2182 $limit = max( 1, min( 500, $limit ) );
1392 2183
2184 + $history = \XSpeed\Score::history();
2185 +
1393 2186 $runs = array();
1394 - foreach ( array_slice( \XSpeed\Score::history(), 0, $limit ) as $run ) {
2187 + foreach ( array_slice( $history, 0, $limit ) as $run ) {
1395 2188 if ( ! is_array( $run ) ) {
1396 2189 continue;
1397 2190 }
1398 2191 $metrics = isset( $run['metrics'] ) && is_array( $run['metrics'] ) ? $run['metrics'] : array();
@@ -1400,8 +2193,13 @@
1400 2193 'provider' => isset( $run['provider'] ) ? (string) $run['provider'] : 'unknown',
1401 2194 'ts' => isset( $run['ts'] ) ? (int) $run['ts'] : 0,
1402 2195 'url' => isset( $run['url'] ) ? (string) $run['url'] : '',
1403 2196 'strategy' => isset( $run['strategy'] ) ? (string) $run['strategy'] : null,
2197 + // A failed audit and a successful one that returned no score
2198 + // both project to score null — ok is the only field that
2199 + // tells them apart, and error says why it failed.
2200 + 'ok' => ! empty( $run['ok'] ),
2201 + 'error' => isset( $run['error'] ) && '' !== $run['error'] ? (string) $run['error'] : null,
1404 2202 // Null, never 0: Score distinguishes "no score" from "scored
1405 2203 // zero", and flattening that reports a failed audit as a
1406 2204 // catastrophic result.
1407 2205 'score' => isset( $run['score'] ) && is_numeric( $run['score'] ) ? (int) $run['score'] : null,
@@ -1418,9 +2216,9 @@
1418 2216 }
1419 2217
1420 2218 return array(
1421 2219 'runs' => $runs,
1422 - 'total' => count( \XSpeed\Score::history() ),
2220 + 'total' => count( $history ),
1423 2221 );
1424 2222 }
1425 2223
1426 2224 /**
@@ -1485,13 +2283,36 @@
1485 2283 return Cli_Bridge::run( 'cf', array( 'verify' ) );
1486 2284 }
1487 2285
1488 2286 /**
1489 - * Run a PageSpeed Insights audit (Pro).
2287 + * Run an external audit on any install.
1490 2288 *
1491 - * @param array $args { url?:string, strategy?:string }.
2289 + * Shares run_pagespeed's body: that handler ALREADY falls back to
2290 + * `xspeed score run` when the Pro `xspeed psi` command is absent, so the
2291 + * engine could always do this on Free — the tool was simply dropped from
2292 + * the catalog before anyone could call it. The only thing missing was a
2293 + * name that survives on a Free install. (#147)
2294 + *
2295 + * @param array $args target / strategy / provider.
1492 2296 * @return array|\WP_Error
1493 2297 */
2298 + public static function run_score( array $args ) {
2299 + // `target` is the CLI's name for it (--url is a reserved WP-CLI global,
2300 + // so the score command deliberately uses --target). Accept both here
2301 + // and normalise, so an assistant that guessed `url` still works.
2302 + if ( ! empty( $args['target'] ) && empty( $args['url'] ) ) {
2303 + $args['url'] = (string) $args['target'];
2304 + }
2305 + return self::run_pagespeed( $args );
2306 + }
2307 +
2308 + /**
2309 + * Run an external performance audit. Prefers the Pro engine when present,
2310 + * otherwise drives Free's own score command.
2311 + *
2312 + * @param array $args { url?:string, strategy?:string, provider?:string, force?:bool }.
2313 + * @return array|\WP_Error
2314 + */
1494 2315 public static function run_pagespeed( array $args ) {
1495 2316 $options = array();
1496 2317 if ( ! empty( $args['url'] ) ) {
1497 2318 $options['url'] = (string) $args['url'];
@@ -1498,8 +2319,16 @@
1498 2319 }
1499 2320 if ( ! empty( $args['strategy'] ) ) {
1500 2321 $options['strategy'] = (string) $args['strategy'];
1501 2322 }
2323 + // Advertised in run_score's schema, and the Free score handler already
2324 + // branches on it (ScoreModule::cli_handler reads $assoc['provider']),
2325 + // so dropping it here meant a GTmetrix request ran a PSI audit and
2326 + // reported ok:true — spending the wrong provider's quota with nothing
2327 + // in the response to say so. (QA B1 on #162)
2328 + if ( ! empty( $args['provider'] ) ) {
2329 + $options['provider'] = (string) $args['provider'];
2330 + }
1502 2331 // Was reachable only via the generated xspeed_psi alias, which this
1503 2332 // change removes — so it moves onto the typed tool rather than being
1504 2333 // lost with it.
1505 2334 if ( ! empty( $args['force'] ) && filter_var( $args['force'], FILTER_VALIDATE_BOOLEAN ) ) {
@@ -1505,14 +2334,35 @@
1505 2334 if ( ! empty( $args['force'] ) && filter_var( $args['force'], FILTER_VALIDATE_BOOLEAN ) ) {
1506 2335 $options['force'] = true;
1507 2336 }
1508 2337
1509 - // Prefer the richer Pro engine when it's installed; otherwise drive
1510 - // Free's own score command. Same tool name either way — an assistant
1511 - // asking for a PageSpeed audit shouldn't have to know which tier the
1512 - // site runs, and the two write to the same run history.
1513 - if ( isset( Cli_Bridge::commands()['xspeed psi'] ) ) {
2338 + /*
2339 + * Prefer the richer Pro engine when it's installed; otherwise drive
2340 + * Free's own score command. Same tool name either way — an assistant
2341 + * asking for a PageSpeed audit shouldn't have to know which tier the
2342 + * site runs, and the two write to the same run history.
2343 + *
2344 + * EXCEPT when a provider was named that the Pro engine cannot serve.
2345 + * `xspeed psi` is PageSpeed-only: it declares no --provider and
2346 + * discards the option, so preferring it purely because it exists made
2347 + * `provider: "gtmetrix"` run PSI and answer ok:true — the same silent
2348 + * wrong-provider bug this tool just fixed on Free, reappearing only on
2349 + * Pro. A site that configures GTmetrix would have stopped getting it
2350 + * the moment Pro activated. Free's `score` command reads $assoc
2351 + * ['provider'] and branches, so route there instead. (QA R1 on #162)
2352 + */
2353 + $wants_non_psi = isset( $options['provider'] ) && 'psi' !== strtolower( (string) $options['provider'] );
2354 + if ( isset( Cli_Bridge::commands()['xspeed psi'] ) && ! $wants_non_psi ) {
1514 2355 return Cli_Bridge::run( 'psi', array(), $options );
2356 + }
2357 +
2358 + // The Free `score` command reads --target, not --url: `url` is a
2359 + // reserved WP-CLI global, so a value passed as `url` never reaches the
2360 + // handler and the requested page is silently ignored in favour of the
2361 + // default. Translate rather than passing it through. (#147)
2362 + if ( isset( $options['url'] ) ) {
2363 + $options['target'] = $options['url'];
2364 + unset( $options['url'] );
1515 2365 }
1516 2366 return Cli_Bridge::run( 'score', array( 'run' ), $options );
1517 2367 }
1518 2368