(Plugin_Upgrader::upgrade() * injects hook_extra.temp_backup itself — verified against * wp-admin/includes/class-plugin-upgrader.php on this install). After the * update we reactivate the plugin if it was active, run a loopback health * probe (GET home + REST ping, fatal-marker scrape), and restore the backup * if the site broke. * * Core-verified subtleties this class is built around (do not "simplify" * them away): * * 1. On a SUCCESSFUL upgrade core leaves $upgrader->temp_restores empty and * schedules delete_temp_backup() on shutdown (priority 100). So a * post-success rollback must (a) call restore_temp_backup() with EXPLICIT * backup args and (b) first remove core's shutdown deletion, or a fatal * during our reactivation step would let shutdown destroy the rollback. * 2. The 'shutdown' action fires even after a fatal error * (register_shutdown_function in wp-settings.php), so the fail-safe * restore armed before reactivation genuinely covers include-time fatals. * 3. move_to_temp_backup_dir() silently skips slug "." — single-file plugins * (e.g. hello.php) get NO backup, so we refuse them: without a backup the * safety promise of this ability cannot be kept. * 4. Outside cron, deactivate_plugin_before_upgrade() silently deactivates an * active plugin and core never reactivates it (active_before/active_after * only toggle maintenance mode during cron). Reactivation is ours, and * activate_plugin() includes the plugin file in-process before adding it * to active_plugins — a fatal there kills this request with the plugin * still deactivated (site stays up; the fail-safe restores old files). */ // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } if ( ! class_exists( 'ATB_Operation_Update_Plugin' ) ) { class ATB_Operation_Update_Plugin extends ATB_Operation { /** * Armed between a successful upgrade and operation completion: if this * request dies (e.g. fatal while reactivating the updated plugin), the * shutdown hook restores the temp backup. Null when disarmed. * * @var array|null { upgrader: Plugin_Upgrader, backup: array, plugin: string } */ protected $atb_failsafe = null; public function slug() { return 'update-plugin'; } public function describe() { return array( 'slug' => $this->slug(), 'risk' => 'high', 'capability' => 'update_plugins', 'summary' => 'Updates one plugin safely: keeps a backup of the current version, checks the ' . 'site still responds after the update, and automatically restores the backup if the site ' . 'breaks. Always call with dry_run first; execution requires the confirm token from the ' . 'dry-run response.', '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' ), '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").', ), ), ), ); } public function dry_run( $atb_input ) { $atb_context = $this->build_context( $atb_input ); $atb_refusal = self::preflight_refusal( $atb_context ); if ( null !== $atb_refusal ) { return $this->output( false, true, array_merge( array( 'refused' => true ), $atb_refusal ) ); } // The safety net must be proven BEFORE the token is issued (v1.5.2 F2). $atb_baseline = ATB_Site_Probe::baseline_refusal(); if ( null !== $atb_baseline ) { return $this->output( false, true, array_merge( array( 'refused' => true ), $atb_baseline ) ); } return $this->output( false, true, array( 'plugin' => $atb_context['file'], 'installed_version' => $atb_context['installed_version'], 'available_version' => $atb_context['available_version'], 'update_available' => true, // Whether the plugin is active is THE risk signal for an // update: inactive means no reactivation step and no // include-time-fatal window (v1.4 F5). 'active' => is_plugin_active( $atb_context['file'] ), ) ); } public function run( $atb_input ) { // The confirm token was already verified by Agent_Toolbelt::execute(). $atb_context = $this->build_context( $atb_input ); $atb_refusal = self::preflight_refusal( $atb_context ); if ( null !== $atb_refusal ) { return $this->output( false, false, array_merge( array( 'refused' => true ), $atb_refusal ) ); } $atb_baseline = ATB_Site_Probe::baseline_refusal(); if ( null !== $atb_baseline ) { return $this->output( false, false, array_merge( array( 'refused' => true ), $atb_baseline ) ); } $atb_file = $atb_context['file']; $atb_was_active = is_plugin_active( $atb_file ); $atb_warnings = array(); $this->load_upgrader_dependencies(); $atb_skin = new Automatic_Upgrader_Skin(); $atb_upgrader = new Plugin_Upgrader( $atb_skin ); $atb_result = $atb_upgrader->upgrade( $atb_file ); if ( true !== $atb_result ) { // Nothing was installed (download/unpack/copy failed or the // transient lost the update). If install_package failed after // the backup was taken, core itself has already scheduled the // shutdown restore (temp_restores is populated on WP_Error). // 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() ) : 'update failed'; return $this->output( false, false, array( 'plugin' => $atb_file, 'previous_version' => $atb_context['installed_version'], 'error' => $atb_error, ), array_merge( array( 'plugin update 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: core must not delete it on // shutdown while our reactivation + 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 ) { // activate_plugin() includes the new main file in-process; a // fatal here ends the request and the fail-safe restores. $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 = $this->health_probe(); $atb_output = array( 'plugin' => $atb_file, 'previous_version' => $atb_context['installed_version'], 'new_version' => $atb_context['available_version'], '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'; } return $this->output( true, false, $atb_output, $atb_warnings ); } // Health probe failed: put the previous 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; // Core cleaned the plugin caches for the NEW version after the // upgrade; the restore put the OLD files back, so without this // any later get_plugins()/update check (persisted by an // external object cache too) would keep reporting the rolled- // back version as installed. Live-caught on wp.local. if ( function_exists( 'wp_clean_plugins_cache' ) ) { wp_clean_plugins_cache( true ); } if ( $atb_was_active && ! is_plugin_active( $atb_file ) ) { // Reactivation of the new version failed earlier; the old, // known-good version goes back to its active state. activate_plugin( $atb_file, '', false, true ); } } else { $atb_warnings[] = 'automatic rollback failed — the new version is still installed; restore ' . dirname( $atb_file ) . ' manually from wp-content/upgrade-temp-backup/plugins/'; } return $this->output( true, false, $atb_output, $atb_warnings ); } /** * Loads the wp-admin machinery the upgrade needs when running outside * wp-admin (abilities via REST/MCP, WP-CLI). class-wp-upgrader.php * pulls in every skin plus Plugin_Upgrader itself. */ 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'; } } /* ---------------------------------------------------------------- * Preflight: resolve the plugin + the refusal matrix (spec §3.5) * -------------------------------------------------------------- */ /** * Gathers everything the refusal matrix needs from live WP. Kept * separate from preflight_refusal() so the matrix itself is pure and * unit-testable without WordPress. * * @param array $atb_input * @return array See preflight_refusal() for the consumed keys. */ 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_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 = self::resolve_plugin_file( $atb_requested, array_keys( $atb_installed ) ); // Refresh update data so the answer reflects wp.org's current // state (core throttles this internally; same freshness the // wp-admin updates screen works with). wp_update_plugins(); $atb_updates = get_site_transient( 'update_plugins' ); $atb_available = null; if ( null !== $atb_file && is_object( $atb_updates ) && isset( $atb_updates->response[ $atb_file ]->new_version ) ) { $atb_available = (string) $atb_updates->response[ $atb_file ]->new_version; } return array( 'requested' => $atb_requested, 'file' => $atb_file, 'installed_version' => ( null !== $atb_file ) ? $atb_installed[ $atb_file ] : null, 'available_version' => $atb_available, 'is_multisite' => is_multisite(), 'self_dir' => basename( AGENT_TOOLBELT_DIR ), ); } /** * The refusal matrix. Pure: decides only from the passed context. * `reason` is a machine code (site-status's agent_protocol teaches * agents to branch on it — v1.4 F1); `error` carries the human sentence. * * @param array $atb_context { * requested string Raw input. * file string|null Resolved plugin file. * installed_version string|null * available_version string|null New version, when an update exists. * is_multisite bool * self_dir string Agent Toolbelt's own directory name. * } * @return array|null { reason: string, error: string }, or null to proceed. */ public static function preflight_refusal( $atb_context ) { if ( ! empty( $atb_context['is_multisite'] ) ) { return array( 'reason' => 'multisite_unsupported', 'error' => 'multisite is not supported: updating plugins 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_update', 'error' => 'refusing to update Agent Toolbelt itself; update 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 update with ' . 'automatic rollback is impossible; update it from wp-admin or via WP-CLI', ); } if ( null === $atb_context['available_version'] ) { return array( 'reason' => 'no_update_available', 'error' => 'no update available for ' . $atb_context['file'] . ' (installed version ' . $atb_context['installed_version'] . ' is current)', ); } return null; } /** * Resolves a slug or plugin file against the installed plugin files. * Pure. An exact file match wins; otherwise the first plugin whose * directory equals the slug. * * @param string $atb_requested Slug ('classic-editor') or file ('dir/file.php'). * @param string[] $atb_installed Installed plugin files (keys of get_plugins()). * @return string|null */ public static function resolve_plugin_file( $atb_requested, $atb_installed ) { if ( '' === $atb_requested ) { return null; } if ( in_array( $atb_requested, $atb_installed, true ) ) { return $atb_requested; } foreach ( $atb_installed as $atb_plugin_file ) { if ( dirname( $atb_plugin_file ) === $atb_requested ) { return $atb_plugin_file; } } return null; } /* ---------------------------------------------------------------- * Loopback health probe (spec §3.5) * -------------------------------------------------------------- */ /** * The shared loopback probe (extracted to ATB_Site_Probe in v1.4 — * behavior identical; see that class for the cache-buster rationale). * * @return array { passed: bool, failures: string[], checks: array } */ protected function health_probe() { return ATB_Site_Probe::run(); } /** * Pure verdict on one probe response: 2xx and no fatal marker = ok. * Delegates to ATB_Site_Probe (kept as a method for back-compat with * existing callers/tests). * * @param int $atb_code HTTP status code (0 when unavailable). * @param string $atb_body Response body. * @return array { ok: bool, reason: string } */ public static function evaluate_probe_response( $atb_code, $atb_body ) { return ATB_Site_Probe::evaluate( $atb_code, $atb_body ); } /* ---------------------------------------------------------------- * Fail-safe (armed only between a successful upgrade and completion) * -------------------------------------------------------------- */ /** * Shutdown handler: if the operation never completed (a fatal killed * the request after the upgrade — most likely while including the * updated plugin during reactivation), restore the previous version * and leave an audit trail. The 'shutdown' action fires after fatals. */ 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-update' . ( ( true === $atb_restored ) ? '; previous version restored, plugin left deactivated' : '; RESTORE FAILED' ), ) ); } } /** * Marks the operation complete so the shutdown fail-safe becomes a no-op. */ protected function disarm_failsafe() { $this->atb_failsafe = null; remove_action( 'shutdown', array( $this, 'failsafe_restore' ), 1 ); } } }