]->package and injects * hook_extra.temp_backup itself). After the swap: reactivate if it was * active, loopback probe, restore the backup (the NEWER version) if the site * broke, shutdown fail-safe covering the reactivation window. The poisoned * transient is always cleaned afterwards — a lingering fake entry would show * a phantom "update" (the downgrade) in wp-admin. * * Dry-run UX: called without `version`, the refusal carries the available * version list — the menu an agent needs to pick a target. The confirm token * is only issued once a concrete valid version is previewed. */ // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } if ( ! class_exists( 'ATB_Operation_Rollback_Plugin' ) ) { class ATB_Operation_Rollback_Plugin extends ATB_Operation { /** * Max versions returned in the dry-run menu (newest first). */ const VERSIONS_CAP = 20; /** * wp.org plugin-information API request timeout, seconds. */ const API_TIMEOUT = 10; /** * Armed between a successful downgrade and operation completion — * same pattern as update-plugin's fail-safe. * * @var array|null { upgrader: Plugin_Upgrader, backup: array, plugin: string } */ protected $atb_failsafe = null; public function slug() { return 'rollback-plugin'; } public function describe() { return array( 'slug' => $this->slug(), 'risk' => 'high', 'capability' => 'update_plugins', 'summary' => 'Rolls one plugin back to an earlier WordPress.org release — the recovery ' . 'step when an update broke the site. Keeps a backup of the current version, checks the ' . 'site still responds after the rollback, and restores the backup if it does not. Only ' . 'WordPress.org-hosted plugins can be rolled back. Dry-run without a version lists the ' . 'available versions; execution requires the confirm token from a dry run with the ' . 'chosen version. Rolling back may reintroduce fixed bugs or vulnerabilities — treat it ' . 'as a temporary measure.', 'annotations' => array( 'destructive' => true, 'idempotent' => false, ), // Prefill for the admin Run panel (v1.4); agents may copy it too. 'example_input' => array( 'plugin' => 'classic-editor', 'version' => '1.6.7' ), 'input_schema' => array( 'type' => 'object', 'required' => array( 'plugin' ), 'properties' => array( 'plugin' => array( 'type' => 'string', 'description' => 'Plugin slug (e.g. "classic-editor") or plugin file (e.g. "classic-editor/classic-editor.php").', ), 'version' => array( 'type' => 'string', 'description' => 'Target version to roll back to. Omit in a dry run to get the list of available versions first.', ), ), ), ); } public function dry_run( $atb_input ) { $atb_context = $this->build_context( $atb_input ); $atb_refusal = $this->preflight_with_versions( $atb_context ); if ( null !== $atb_refusal ) { return $this->output( false, true, array_merge( array( 'refused' => true ), $atb_refusal ) ); } return $this->output( false, true, array( 'plugin' => $atb_context['file'], 'installed_version' => $atb_context['installed_version'], 'target_version' => $atb_context['version'], 'active' => is_plugin_active( $atb_context['file'] ), ), array( 'rolling back may reintroduce bugs or security issues fixed in newer versions; plan to update again once the underlying problem is resolved' ) ); } public function run( $atb_input ) { // The confirm token was already verified by Agent_Toolbelt::execute(). $atb_context = $this->build_context( $atb_input ); $atb_refusal = $this->preflight_with_versions( $atb_context ); if ( null !== $atb_refusal ) { return $this->output( false, false, array_merge( array( 'refused' => true ), $atb_refusal ) ); } $atb_file = $atb_context['file']; $atb_target = $atb_context['version']; $atb_package = $atb_context['versions'][ $atb_target ]; $atb_was_active = is_plugin_active( $atb_file ); $atb_warnings = array(); $this->load_upgrader_dependencies(); // Present the rollback to core as a normal update: upgrade() then // runs its full machinery, temp backup of the CURRENT (newer) // version included. $atb_updates = get_site_transient( 'update_plugins' ); if ( ! is_object( $atb_updates ) ) { $atb_updates = new stdClass(); } if ( ! isset( $atb_updates->response ) || ! is_array( $atb_updates->response ) ) { $atb_updates->response = array(); } $atb_updates->response[ $atb_file ] = (object) array( 'slug' => dirname( $atb_file ), 'plugin' => $atb_file, 'new_version' => $atb_target, 'package' => $atb_package, ); set_site_transient( 'update_plugins', $atb_updates ); $atb_skin = new Automatic_Upgrader_Skin(); $atb_upgrader = new Plugin_Upgrader( $atb_skin ); try { $atb_result = $atb_upgrader->upgrade( $atb_file ); } finally { // Never leave the poisoned entry behind: on success core wipes // the transient itself, but an early failure would leave a // phantom "update" (the downgrade) in wp-admin. delete_site_transient( 'update_plugins' ); } if ( true !== $atb_result ) { // Core's messages name absolute paths; redacted (v1.5.2 F1). $atb_error = is_wp_error( $atb_result ) ? Agent_Toolbelt::redact( $atb_result->get_error_message() ) : 'rollback failed'; return $this->output( false, false, array( 'plugin' => $atb_file, 'previous_version' => $atb_context['installed_version'], 'error' => $atb_error, ), array_merge( array( 'plugin rollback did not complete; the installed version was not replaced' ), array_map( array( 'Agent_Toolbelt', 'redact' ), (array) $atb_skin->get_upgrade_messages() ) ) ); } $atb_backup = array( 'slug' => dirname( $atb_file ), 'src' => WP_PLUGIN_DIR, 'dir' => 'plugins', ); // Take over the backup lifecycle (same as update-plugin): core must // not delete the temp backup on shutdown while reactivation + the // health probe are still ahead. remove_action( 'shutdown', array( $atb_upgrader, 'delete_temp_backup' ), 100 ); $this->atb_failsafe = array( 'upgrader' => $atb_upgrader, 'backup' => $atb_backup, 'plugin' => $atb_file, ); add_action( 'shutdown', array( $this, 'failsafe_restore' ), 1 ); if ( $atb_was_active ) { $atb_activated = activate_plugin( $atb_file, '', false, true ); if ( is_wp_error( $atb_activated ) ) { $atb_warnings[] = 'reactivation failed: ' . Agent_Toolbelt::redact( $atb_activated->get_error_message() ); } } $atb_probe = ATB_Site_Probe::run(); $atb_output = array( 'plugin' => $atb_file, 'previous_version' => $atb_context['installed_version'], 'new_version' => $atb_target, 'health_check' => $atb_probe['passed'] ? 'passed' : 'failed', 'rolled_back' => false, ); if ( $atb_probe['passed'] ) { $this->disarm_failsafe(); $atb_deleted = $atb_upgrader->delete_temp_backup( array( $atb_backup ) ); if ( true !== $atb_deleted ) { $atb_warnings[] = 'temporary backup could not be deleted; core cleans it up within a day'; } $this->refresh_update_data(); return $this->output( true, false, $atb_output, $atb_warnings ); } // The old version broke the site: put the newer version back. $atb_warnings = array_merge( $atb_warnings, $atb_probe['failures'] ); $atb_restored = $atb_upgrader->restore_temp_backup( array( $atb_backup ) ); $this->disarm_failsafe(); if ( true === $atb_restored ) { $atb_output['rolled_back'] = true; if ( function_exists( 'wp_clean_plugins_cache' ) ) { wp_clean_plugins_cache( true ); } if ( $atb_was_active && ! is_plugin_active( $atb_file ) ) { activate_plugin( $atb_file, '', false, true ); } $atb_warnings[] = 'the site stopped responding on the rolled-back version; the previous (newer) version was restored'; } else { $atb_warnings[] = 'automatic restore failed — the rolled-back version is still installed; restore ' . dirname( $atb_file ) . ' manually from wp-content/upgrade-temp-backup/plugins/'; } $this->refresh_update_data(); return $this->output( true, false, $atb_output, $atb_warnings ); } /* ---------------------------------------------------------------- * Preflight + wp.org version list * -------------------------------------------------------------- */ /** * Gathers live context; the wp.org version list is fetched lazily in * preflight_with_versions() only after the cheap refusals pass. * * @param array $atb_input * @return array */ protected function build_context( $atb_input ) { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $atb_requested = ( is_array( $atb_input ) && isset( $atb_input['plugin'] ) ) ? trim( (string) $atb_input['plugin'] ) : ''; $atb_version = ( is_array( $atb_input ) && isset( $atb_input['version'] ) ) ? trim( (string) $atb_input['version'] ) : ''; $atb_installed = array(); foreach ( get_plugins() as $atb_plugin_file => $atb_headers ) { $atb_installed[ $atb_plugin_file ] = isset( $atb_headers['Version'] ) ? (string) $atb_headers['Version'] : ''; } $atb_file = ATB_Operation_Update_Plugin::resolve_plugin_file( $atb_requested, array_keys( $atb_installed ) ); return array( 'requested' => $atb_requested, 'version' => $atb_version, 'file' => $atb_file, 'installed_version' => ( null !== $atb_file ) ? $atb_installed[ $atb_file ] : null, 'is_multisite' => is_multisite(), 'self_dir' => basename( AGENT_TOOLBELT_DIR ), 'versions' => null, // filled by preflight_with_versions ); } /** * Runs the pure refusal matrix, then the wp.org lookup, then the * version-specific refusals. Mutates $atb_context['versions'] so * run() can reuse the fetched package URL. * * @param array $atb_context build_context() output, by reference. * @return array|null Refusal ({ reason, error, ... }) or null. */ protected function preflight_with_versions( &$atb_context ) { $atb_refusal = self::preflight_refusal( $atb_context ); if ( null !== $atb_refusal ) { return $atb_refusal; } // Baseline probe before the wp.org lookup (v1.5.2 F2): a site that // cannot reach itself gets the honest refusal before it is shown // a version menu it can never use. $atb_baseline = ATB_Site_Probe::baseline_refusal(); if ( null !== $atb_baseline ) { return $atb_baseline; } $atb_versions = $this->fetch_versions( dirname( $atb_context['file'] ) ); if ( null === $atb_versions ) { return array( 'reason' => 'versions_unavailable', 'error' => 'could not retrieve the version list from WordPress.org — premium or custom plugins cannot be rolled back by this ability', ); } $atb_context['versions'] = $atb_versions; $atb_menu = array_slice( array_keys( $atb_versions ), 0, self::VERSIONS_CAP ); if ( '' === $atb_context['version'] ) { return array( 'reason' => 'missing_version_input', 'error' => 'pick a target version and dry-run again to get the confirm token', 'installed_version' => $atb_context['installed_version'], 'available_versions' => $atb_menu, ); } if ( ! isset( $atb_versions[ $atb_context['version'] ] ) ) { return array( 'reason' => 'version_not_available', 'error' => 'version ' . $atb_context['version'] . ' is not available on WordPress.org for this plugin', 'installed_version' => $atb_context['installed_version'], 'available_versions' => $atb_menu, ); } if ( $atb_context['version'] === $atb_context['installed_version'] ) { return array( 'reason' => 'version_already_installed', 'error' => 'version ' . $atb_context['version'] . ' is already installed', ); } return null; } /** * The pure part of the refusal matrix (no network). reason = machine * code, error = sentence (v1.4 F1 convention). * * @param array $atb_context * @return array|null */ public static function preflight_refusal( $atb_context ) { if ( ! empty( $atb_context['is_multisite'] ) ) { return array( 'reason' => 'multisite_unsupported', 'error' => 'multisite is not supported: replacing plugin files network-wide is out of scope for this ability', ); } if ( '' === $atb_context['requested'] ) { return array( 'reason' => 'missing_plugin_input', 'error' => 'missing required input: plugin (slug or plugin file)', ); } if ( null === $atb_context['file'] ) { return array( 'reason' => 'plugin_not_installed', 'error' => 'plugin not installed: ' . $atb_context['requested'], ); } if ( dirname( $atb_context['file'] ) === $atb_context['self_dir'] ) { return array( 'reason' => 'self_rollback', 'error' => 'refusing to roll back Agent Toolbelt itself; manage it from wp-admin or via WP-CLI', ); } if ( '.' === dirname( $atb_context['file'] ) ) { return array( 'reason' => 'single_file_plugin', 'error' => 'single-file plugins cannot be backed up by WordPress core, so a safe rollback with automatic restore is impossible', ); } return null; } /** * Fetches the version => zip-URL map from the wp.org plugin-information * API, 'trunk' excluded, sorted newest first. Null when the plugin is * not on wp.org or the API call failed. * * @param string $atb_slug Plugin directory slug. * @return array|null */ protected function fetch_versions( $atb_slug ) { $atb_url = 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information' . '&request%5Bslug%5D=' . rawurlencode( $atb_slug ) . '&request%5Bfields%5D%5Bversions%5D=1'; $atb_response = wp_remote_get( $atb_url, array( 'timeout' => self::API_TIMEOUT ) ); if ( is_wp_error( $atb_response ) || 200 !== (int) wp_remote_retrieve_response_code( $atb_response ) ) { return null; } $atb_data = json_decode( (string) wp_remote_retrieve_body( $atb_response ), true ); if ( ! is_array( $atb_data ) || empty( $atb_data['versions'] ) || ! is_array( $atb_data['versions'] ) ) { return null; } $atb_versions = array(); foreach ( $atb_data['versions'] as $atb_version => $atb_package ) { if ( 'trunk' === $atb_version || ! is_string( $atb_package ) || '' === $atb_package ) { continue; } $atb_versions[ (string) $atb_version ] = $atb_package; } if ( empty( $atb_versions ) ) { return null; } uksort( $atb_versions, static function ( $atb_a, $atb_b ) { return version_compare( $atb_b, $atb_a ); } ); return $atb_versions; } /** * Rebuilds honest update data after the transient was manipulated — * the rolled-back plugin should immediately show its real pending * update again. */ protected function refresh_update_data() { if ( function_exists( 'wp_clean_plugins_cache' ) ) { wp_clean_plugins_cache( true ); } if ( function_exists( 'wp_update_plugins' ) ) { wp_update_plugins(); } } /** * Loads the wp-admin machinery outside wp-admin (same as update-plugin). */ protected function load_upgrader_dependencies() { if ( ! class_exists( 'WP_Upgrader' ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; } if ( ! function_exists( 'request_filesystem_credentials' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( ! function_exists( 'wp_update_plugins' ) ) { require_once ABSPATH . 'wp-includes/update.php'; } } /* ---------------------------------------------------------------- * Fail-safe — mirror of update-plugin's (same core subtleties) * -------------------------------------------------------------- */ /** * Shutdown handler: if the operation never completed after the swap, * restore the temp backup (the newer version) and leave an audit trail. */ public function failsafe_restore() { if ( empty( $this->atb_failsafe ) ) { return; } $atb_failsafe = $this->atb_failsafe; $this->atb_failsafe = null; $atb_restored = $atb_failsafe['upgrader']->restore_temp_backup( array( $atb_failsafe['backup'] ) ); if ( true === $atb_restored && function_exists( 'wp_clean_plugins_cache' ) ) { wp_clean_plugins_cache( true ); } if ( class_exists( 'Agent_Toolbelt_Log' ) ) { Agent_Toolbelt_Log::write( array( 'ability' => $this->slug(), 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, 'dry_run' => false, 'result' => ( true === $atb_restored ) ? 'rolled_back' : 'error', 'summary' => '{"plugin":"' . $atb_failsafe['plugin'] . '"} => fail-safe restore after the request died post-rollback' . ( ( true === $atb_restored ) ? '; the newer version was restored, plugin left deactivated' : '; RESTORE FAILED' ), ) ); } } /** * Marks the operation complete so the shutdown fail-safe is a no-op. */ protected function disarm_failsafe() { $this->atb_failsafe = null; remove_action( 'shutdown', array( $this, 'failsafe_restore' ), 1 ); } } }