PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / abilities / zipai / system / plugin-install.php

plugin-install.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at inc/abilities/zipai/system/plugin-install.php

328 lines 13.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Install — server-side execution.
4 *
5 * Runs the install in-process against WP core's `Plugin_Upgrader` under
6 * the App Password user's identity (set by `REST_API::is_basic_authenticated()`
7 * before this handler dispatches). Capability checks run as that user
8 * via `current_user_can()` so unauthorised roles cannot install.
9 *
10 * App Password auth is the WP-blessed pattern for third-party services:
11 * a server-side install under a user-bound credential is fully compliant
12 * and returns real success/error in one round-trip.
13 *
14 * @since 0.0.5
15 * @package zip-ai
16 */
17
18 namespace ZipAI\MCP\Classes\Abilities\Zipai\System;
19
20 use Plugin_Upgrader;
21 use WP_Ajax_Upgrader_Skin;
22 use ZipAI\MCP\Classes\Abilities\Zipai\System\PluginResolver;
23 use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
24 use ZipAI\MCP\Classes\Core\Response;
25 use ZipAI\MCP\Classes\Core\Tool_Types;
26
27 if ( ! defined( 'ABSPATH' ) ) {
28 exit;
29 }
30
31 class PluginInstall extends Abstract_Ability {
32
33 /**
34 * Flags this ability as destructive (mutates site state).
35 *
36 * @var bool
37 */
38 protected $is_destructive = true;
39
40 /**
41 * Configures the ability's id, label, description and metadata.
42 *
43 * @return void
44 */
45 public function configure() {
46 $this->id = 'zipai/install-plugin';
47 $this->label = 'Install Plugin';
48 $this->description = 'Install a plugin from the WordPress.org repository by slug. '
49 . 'Runs in-process via `Plugin_Upgrader` under the App-Password user\'s identity. '
50 . 'The user must have the `install_plugins` capability (and `activate_plugins` when `status: "active"`). '
51 . 'Returns synchronously with the actual result — no browser round-trip.';
52 $this->capability = 'install_plugins';
53
54 $this->meta = array(
55 'tool_type' => Tool_Types::WRITE,
56 );
57 }
58
59 /**
60 * Returns the tool-type classification for this ability.
61 *
62 * @return string One of the Tool_Types constants.
63 */
64 public function get_tool_type() {
65 return Tool_Types::WRITE;
66 }
67
68 /**
69 * Returns the JSON Schema for this ability's input arguments.
70 *
71 * @return array<string,mixed> JSON Schema describing accepted arguments.
72 */
73 public function get_input_schema() {
74 return array(
75 'type' => 'object',
76 'required' => array( 'slug' ),
77 'additionalProperties' => false,
78 'properties' => array(
79 'slug' => array(
80 'type' => 'string',
81 'pattern' => '^[a-z0-9][a-z0-9-]*$',
82 'description' => 'Plugin slug on WordPress.org (e.g. "contact-form-7", "wordpress-seo"). Lowercase, hyphen-separated. NOT the full plugin path or display name.',
83 ),
84 'status' => array(
85 'type' => 'string',
86 'enum' => array( 'inactive', 'active' ),
87 'default' => 'inactive',
88 'description' => 'Optional target state after install. "active" requires `activate_plugins` capability too.',
89 ),
90 ),
91 );
92 }
93
94 /**
95 * Installs a WordPress.org plugin by slug, optionally activating it.
96 *
97 * @param array<string,mixed> $args Validated input arguments.
98 * @return array<string,mixed> Standardized success or error response.
99 */
100 public function execute( $args ) {
101 $raw_slug = isset( $args['slug'] ) && is_string( $args['slug'] ) ? $args['slug'] : '';
102 if ( '' === trim( $raw_slug ) ) {
103 return Response::error( 'Plugin slug is required (e.g. "contact-form-7").' );
104 }
105 $slug = sanitize_key( $raw_slug );
106 // sanitize_key silently strips spaces/casing/punctuation — but an LLM
107 // emitting "Bad Slug!" or "../evil" should fail loudly, not get
108 // silently rewritten to "badslug" / "evil". Reject when sanitisation
109 // changed the input or the result doesn't match the .org slug shape.
110 if ( $slug !== $raw_slug || ! preg_match( '/^[a-z0-9][a-z0-9-]*$/', $slug ) ) {
111 return Response::error( 'Invalid plugin slug. Use the lowercase, hyphen-separated WordPress.org slug (e.g. "contact-form-7").' );
112 }
113
114 $want_active = isset( $args['status'] ) && 'active' === $args['status'];
115 if ( $want_active && ! current_user_can( 'activate_plugins' ) ) {
116 return Response::error( 'Connected user lacks `activate_plugins` capability — cannot install with status=active.' );
117 }
118
119 // Resolve installed state first — needs only plugin.php (not auto-loaded
120 // in REST context).
121 require_once ABSPATH . 'wp-admin/includes/plugin.php';
122
123 // Already-installed fast path — mirror install-bundled-plugin.php's
124 // resolver check. WP core's Plugin_Upgrader::install() aborts with
125 // `folder_exists` on an installed plugin, so resolving first turns a
126 // redundant install into an idempotent success (optionally activating)
127 // and skips the filesystem + WP.org round-trips entirely.
128 //
129 // Match ONLY the folder component (resolve_installed_folder), not a
130 // root-level single-file plugin whose basename happens to equal the
131 // slug — install takes a wp.org folder slug, so a bare-filename hit
132 // (e.g. a "redirects.php" snippet for slug "redirects") is a false
133 // positive that would report/activate the wrong file.
134 $existing_file = PluginResolver::resolve_installed_folder( $slug );
135 if ( null !== $existing_file ) {
136 return $this->finalize_install( $slug, $existing_file, $want_active, true );
137 }
138
139 // Not installed — the download + extract below writes files, so gate it
140 // on DISALLOW_FILE_MODS here (not before the fast path: activating an
141 // already-installed plugin modifies no files, so a locked-down host must
142 // still get the idempotent success). Parity with ThemeInstall.
143 if ( ! wp_is_file_mod_allowed( 'zipai_install_plugin' ) ) {
144 return Response::error( 'Plugin installation is disabled on this site (DISALLOW_FILE_MODS or the file_mod_allowed filter).' );
145 }
146 if ( is_multisite() && ! is_super_admin() ) {
147 return Response::error( 'On multisite, only network super-admins may install plugins.' );
148 }
149
150 // Load the rest of the upgrader machinery (not auto-loaded in REST
151 // context) only now that we actually need to download + install.
152 require_once ABSPATH . 'wp-admin/includes/file.php';
153 require_once ABSPATH . 'wp-admin/includes/misc.php';
154 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
155 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
156 require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
157
158 // Initialise WP_Filesystem. On FTP-mode hosts without stored
159 // credentials this fails — return a clear, actionable error
160 // instead of a deep upgrader stack trace.
161 if ( ! WP_Filesystem() ) {
162 return Response::error(
163 'Filesystem credentials required for plugin install. '
164 . 'Configure FS_METHOD or store FTP credentials in wp-config.php.'
165 );
166 }
167
168 // Fetch plugin info from WP.org to obtain the download_link.
169 $api = plugins_api( 'plugin_information', array( 'slug' => $slug ) );
170 if ( is_wp_error( $api ) ) {
171 return Response::error( sprintf( 'Could not fetch plugin "%s" from WP.org: %s', $slug, $api->get_error_message() ) );
172 }
173 $download_link = is_object( $api ) && isset( $api->download_link ) && is_string( $api->download_link ) ? $api->download_link : '';
174 if ( '' === $download_link ) {
175 return Response::error( sprintf( 'Plugin "%s" has no download link on WP.org.', $slug ) );
176 }
177
178 // Host allowlist on the WP.org-supplied download URL. `plugins_api()`
179 // hits `api.wordpress.org`, but the returned `download_link` is data
180 // the network gave us — DNS hijack, TLS-MITM on a host with
181 // `ZIPAI_MCP_DISABLE_SSL_VERIFY`, or a transient poisoning of the
182 // `plugins_api_result` filter could otherwise smuggle an
183 // attacker-controlled archive into `Plugin_Upgrader::install()`
184 // under super-admin identity. Hard-pin the host so only
185 // `downloads.wordpress.org` archives are honoured.
186 $download_host = wp_parse_url( $download_link, PHP_URL_HOST );
187 if ( 'downloads.wordpress.org' !== $download_host ) {
188 return Response::error(
189 sprintf(
190 'Refusing to install plugin "%s": download host "%s" is not downloads.wordpress.org.',
191 $slug,
192 is_string( $download_host ) ? $download_host : ''
193 )
194 );
195 }
196
197 $upgrader = new Plugin_Upgrader( new WP_Ajax_Upgrader_Skin() );
198 $result = $upgrader->install( $download_link );
199
200 if ( is_wp_error( $result ) ) {
201 // A `folder_exists` here means the resolver fast path did NOT see the
202 // plugin (get_plugins() skips a directory whose main file lacks a
203 // valid header), yet a same-named folder is on disk — a partial or
204 // corrupted prior install. Retrying installs nothing, so say what is
205 // actually wrong instead of the generic upgrader string (which reads
206 // as a transient failure and drives a useless "retry"). We do NOT
207 // auto-overwrite: clobbering an unrecognised folder risks destroying
208 // user files or an unrelated plugin.
209 if ( 'folder_exists' === $result->get_error_code() ) {
210 // The blocking folder is the archive's top-level dir (WP core puts
211 // its full path in the error data), which is not guaranteed to
212 // equal the requested slug — a plugin whose zip top-folder differs
213 // would otherwise point the user at a path that does not exist.
214 // Report the real folder so the remediation actually unblocks.
215 $conflict_dir = $result->get_error_data();
216 $folder = is_string( $conflict_dir ) && '' !== $conflict_dir ? basename( $conflict_dir ) : $slug;
217 return Response::error(
218 sprintf(
219 'A "%1$s" folder already exists in wp-content/plugins but is not a recognisable plugin (likely a partial or corrupted earlier install). Delete wp-content/plugins/%1$s, then install again. Reinstalling will not overwrite it.',
220 $folder
221 )
222 );
223 }
224 return Response::error( sprintf( 'Install failed: %s', $result->get_error_message() ) );
225 }
226 // `install()` returns null on certain failure paths (filesystem
227 // init reset mid-flight, archive extraction issue). Surface as
228 // an explicit failure rather than letting it look like success.
229 if ( true !== $result ) {
230 return Response::error( 'Plugin install did not complete successfully.' );
231 }
232
233 $plugin_file = $upgrader->plugin_info();
234 if ( ! is_string( $plugin_file ) || '' === $plugin_file ) {
235 return Response::error( 'Plugin installed but main file path could not be determined.' );
236 }
237
238 return $this->finalize_install( $slug, $plugin_file, $want_active, false );
239 }
240
241 /**
242 * Activates (when requested and not already active) an installed plugin and
243 * builds the success response. Shared by the fresh-install and
244 * already-installed paths so the activate + return logic lives in one place.
245 *
246 * @param string $slug Sanitised plugin slug.
247 * @param string $plugin_file Resolved main plugin file (folder/file.php).
248 * @param bool $want_active Whether status=active was requested.
249 * @param bool $already_installed Whether the plugin was already on disk.
250 * @return array<string,mixed> Standardized success or error response.
251 */
252 private function finalize_install( $slug, $plugin_file, $want_active, $already_installed ) {
253 // On a fresh install, do NOT trust a stale `active_plugins` entry —
254 // is_plugin_active() only reads that option, and if the folder was
255 // removed outside WP while active, core hasn't pruned it in this REST
256 // request. Treating it as active would skip activate_plugin() and with it
257 // validate_plugin_requirements() (the activation hooks are suppressed here
258 // anyway via $silent=true), yet still report "installed and activated".
259 // Force the activation to actually run.
260 $is_active = $already_installed ? is_plugin_active( $plugin_file ) : false;
261 $activated = false;
262 if ( $want_active && ! $is_active ) {
263 $activate_result = activate_plugin( $plugin_file, '', false, true );
264 if ( is_wp_error( $activate_result ) ) {
265 return Response::error( sprintf( 'Installed but activation failed: %s', $activate_result->get_error_message() ) );
266 }
267 $is_active = is_plugin_active( $plugin_file );
268 // Verify the write actually took — an `active_plugins`/`activate_plugin`
269 // filter can reject silently without a WP_Error. Parity with
270 // ThemeInstall / InstallBundledPlugin.
271 if ( ! $is_active ) {
272 return Response::error( sprintf( 'Plugin "%s" installed but could not be activated.', $slug ) );
273 }
274 $activated = true;
275 }
276
277 // Clear the cache only when something actually changed — a fresh install
278 // or an activation this call performed. A pure already-installed,
279 // already-active no-op skips it (parity with ThemeInstall).
280 if ( ! $already_installed || $activated ) {
281 wp_clean_plugins_cache();
282 }
283
284 return array(
285 'success' => true,
286 'message' => sprintf(
287 'Plugin "%s" %s%s.',
288 $slug,
289 $already_installed ? 'already installed' : 'installed',
290 // Report intent + state, not state alone: never claim "and
291 // activated" for a default-status call on an already-active plugin
292 // (matches ThemeInstall).
293 ( $want_active && $is_active ) ? ' and activated' : ''
294 ),
295 'data' => array(
296 'slug' => $slug,
297 'plugin_file' => $plugin_file,
298 'active' => $is_active,
299 ),
300 );
301 }
302
303 /**
304 * Returns the JSON Schema for this ability's response.
305 *
306 * @return array<string,mixed> JSON Schema describing the response shape.
307 */
308 public function get_output_schema() {
309 return array(
310 'type' => 'object',
311 'required' => array( 'success' ),
312 'additionalProperties' => true,
313 'properties' => array(
314 'success' => array( 'type' => 'boolean' ),
315 'message' => array( 'type' => 'string' ),
316 'data' => array(
317 'type' => 'object',
318 'properties' => array(
319 'slug' => array( 'type' => 'string' ),
320 'plugin_file' => array( 'type' => 'string' ),
321 'active' => array( 'type' => 'boolean' ),
322 ),
323 ),
324 ),
325 );
326 }
327 }
328