$this->slug(), 'risk' => 'high', 'capability' => 'activate_plugins', 'summary' => 'Activates or deactivates one plugin, then checks the site still responds ' . 'and automatically reverts the change if it does not. Use to isolate a conflict a ' . 'diagnosis found, or to disable a plugin that is breaking the site. Refuses to touch ' . 'Agent Toolbelt itself. Always call with dry_run first; execution requires the confirm ' . 'token from the dry-run response.', 'annotations' => array( 'destructive' => true, 'idempotent' => true, ), // Prefill for the admin Run panel (v1.4); agents may copy it too. 'example_input' => array( 'plugin' => 'classic-editor', 'action' => 'deactivate' ), 'input_schema' => array( 'type' => 'object', 'required' => array( 'plugin', 'action' ), 'properties' => array( 'plugin' => array( 'type' => 'string', 'description' => 'Plugin slug (e.g. "classic-editor") or plugin file (e.g. "classic-editor/classic-editor.php").', ), 'action' => array( 'type' => 'string', 'enum' => array( 'activate', 'deactivate' ), 'description' => 'Target state change.', ), ), ), ); } 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 ) ); } $atb_warnings = array(); if ( 'activate' === $atb_context['action'] ) { $atb_warnings[] = 'activation runs the plugin\'s code immediately; if the site stops responding the change is automatically reverted'; } return $this->output( false, true, array( 'plugin' => $atb_context['file'], 'action' => $atb_context['action'], 'active' => $atb_context['active'], ), $atb_warnings ); } 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 ) ); } return ( 'activate' === $atb_context['action'] ) ? $this->do_activate( $atb_context['file'] ) : $this->do_deactivate( $atb_context['file'] ); } /** * Activation path: fail-safe armed around the state change, probe * after, silent deactivation back on probe failure (a plugin that * just broke the site must not get its deactivation hooks run too). * * @param string $atb_file Plugin file. * @return array Common output shape. */ protected function do_activate( $atb_file ) { $this->atb_failsafe = array( 'plugin' => $atb_file ); add_action( 'shutdown', array( $this, 'failsafe_deactivate' ), 1 ); $atb_activated = activate_plugin( $atb_file ); if ( is_wp_error( $atb_activated ) ) { // Clean failure — core made no state change. $this->disarm_failsafe(); return $this->output( false, false, array( 'plugin' => $atb_file, 'action' => 'activate', 'active' => is_plugin_active( $atb_file ), 'error' => Agent_Toolbelt::redact( $atb_activated->get_error_message() ), 'rolled_back' => false, ) ); } $atb_probe = ATB_Site_Probe::run(); $atb_output = array( 'plugin' => $atb_file, 'action' => 'activate', 'active' => true, 'health_check' => $atb_probe['passed'] ? 'passed' : 'failed', 'rolled_back' => false, ); if ( $atb_probe['passed'] ) { $this->disarm_failsafe(); return $this->output( true, false, $atb_output ); } deactivate_plugins( $atb_file, true ); $this->disarm_failsafe(); $atb_output['active'] = is_plugin_active( $atb_file ); $atb_output['rolled_back'] = true; return $this->output( true, false, $atb_output, array_merge( $atb_probe['failures'], array( 'the site stopped responding after activation; the plugin was deactivated again' ) ) ); } /** * Deactivation path: deactivate (hooks run, as wp-admin would), probe, * silent reactivation on probe failure. * * @param string $atb_file Plugin file. * @return array Common output shape. */ protected function do_deactivate( $atb_file ) { deactivate_plugins( $atb_file ); $atb_probe = ATB_Site_Probe::run(); $atb_output = array( 'plugin' => $atb_file, 'action' => 'deactivate', 'active' => false, 'health_check' => $atb_probe['passed'] ? 'passed' : 'failed', 'rolled_back' => false, ); if ( $atb_probe['passed'] ) { return $this->output( true, false, $atb_output ); } // Silent: the plugin's own state was already set up by its // original activation; re-running activation hooks could duplicate // scheduled events or setup work. activate_plugin( $atb_file, '', false, true ); $atb_output['active'] = is_plugin_active( $atb_file ); $atb_output['rolled_back'] = true; return $this->output( true, false, $atb_output, array_merge( $atb_probe['failures'], array( 'the site stopped responding after deactivation; the plugin was activated again' ) ) ); } /* ---------------------------------------------------------------- * Preflight * -------------------------------------------------------------- */ /** * Gathers everything the refusal matrix needs from live WP. * * @param array $atb_input * @return array Consumed by preflight_refusal(). */ 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_action = ( is_array( $atb_input ) && isset( $atb_input['action'] ) ) ? strtolower( trim( (string) $atb_input['action'] ) ) : ''; $atb_file = ATB_Operation_Update_Plugin::resolve_plugin_file( $atb_requested, array_keys( get_plugins() ) ); return array( 'requested' => $atb_requested, 'action' => $atb_action, 'file' => $atb_file, 'active' => ( null !== $atb_file ) && is_plugin_active( $atb_file ), 'is_multisite' => is_multisite(), 'self_dir' => basename( AGENT_TOOLBELT_DIR ), ); } /** * The refusal matrix. Pure; reason = machine code, error = sentence * (same convention as update-plugin since v1.4 F1). * * @param array $atb_context build_context() output. * @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: network activation semantics are out of scope for this ability', ); } if ( ! in_array( $atb_context['action'], array( 'activate', 'deactivate' ), true ) ) { return array( 'reason' => 'invalid_action', 'error' => 'action must be "activate" or "deactivate"', ); } 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_toggle', 'error' => 'refusing to activate or deactivate Agent Toolbelt itself: an agent must not switch off its own guardrails and audit trail; manage it from wp-admin', ); } if ( ( 'activate' === $atb_context['action'] ) === (bool) $atb_context['active'] ) { return array( 'reason' => 'already_in_state', 'error' => 'the plugin is already ' . ( $atb_context['active'] ? 'active' : 'inactive' ), ); } return null; } /* ---------------------------------------------------------------- * Fail-safe (armed only while an activation is in flight) * -------------------------------------------------------------- */ /** * Shutdown handler: if the request died after the plugin reached * active_plugins (activation-adjacent fatal in this request), take it * back out with direct option surgery — the plugin file just proved * unloadable, so deactivate_plugins()'s hooks must not run. No-op * when the plugin never made it into the option. */ public function failsafe_deactivate() { if ( empty( $this->atb_failsafe ) ) { return; } $atb_file = $this->atb_failsafe['plugin']; $this->atb_failsafe = null; $atb_active = (array) get_option( 'active_plugins', array() ); $atb_index = array_search( $atb_file, $atb_active, true ); if ( false === $atb_index ) { return; } unset( $atb_active[ $atb_index ] ); update_option( 'active_plugins', array_values( $atb_active ) ); 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' => 'rolled_back', 'summary' => '{"plugin":"' . $atb_file . '","action":"activate"} => fail-safe deactivation after the request died mid-activation', ) ); } } /** * 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_deactivate' ), 1 ); } } }