PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.13
ZIP AI – AI Website Builder & AI Agent v0.0.13
0.0.14 0.0.13 0.0.12 0.0.11 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 / builder / install-bundled-plugin.php

install-bundled-plugin.php in ZIP AI – AI Website Builder & AI Agent 0.0.13, at inc/abilities/zipai/builder/install-bundled-plugin.php

284 lines 11.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Install Bundled Plugin — a server-side ability. The website builder uses it
4 * in place of `wp plugin install <slug> --activate` and
5 * `wp plugin activate <slug>`. RunWpCli's dispatcher now refuses both of
6 * those. It redirects them to the browser-proxied `zipai/install-plugin` and
7 * `zipai/activate-plugin` js_hook tools. Those tools cannot run from a
8 * server-to-server call. There is no browser session for them.
9 *
10 * This ability runs `Plugin_Upgrader` server-side. This is the same path the
11 * admin "Install Now" button uses. A server-side install is acceptable here
12 * for these reasons:
13 * 1. The caller is the trusted builder backend (Bearer-auth).
14 * 2. The acting WP user must have `install_plugins` and `activate_plugins`.
15 * `setup_user_context()` resolves this user.
16 * 3. The slug is limited to lower-case alphanumerics and hyphens.
17 * These are the WP.org plugin-directory naming rules.
18 * 4. The code honours `DISALLOW_FILE_MODS`.
19 *
20 * The ability is hidden from the LLM catalog (`meta.visibility = internal`).
21 * So chat-driven installs still flow through the browser-proxied path.
22 *
23 * @since 0.0.5
24 * @package zip-ai
25 */
26
27 namespace ZipAI\MCP\Classes\Abilities\Zipai\Builder;
28
29 use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
30 use ZipAI\MCP\Classes\Core\Tool_Types;
31 use ZipAI\MCP\Classes\Core\Response;
32 use ZipAI\MCP\Classes\Core\Helper;
33 use ZipAI\MCP\Classes\Core\Plugin_Abilities_Toggler;
34
35 defined( 'ABSPATH' ) || exit;
36
37 /**
38 * Ability: install (if missing) and activate a wp.org plugin slug.
39 */
40 class InstallBundledPlugin extends Abstract_Ability {
41
42 /**
43 * Flags this ability as destructive (mutates site state).
44 *
45 * @var bool
46 */
47 protected $is_destructive = true;
48
49 /**
50 * Configures the ability's id, label, description and metadata.
51 *
52 * @return void
53 */
54 public function configure() {
55 $this->id = 'zipai/install-bundled-plugin';
56 $this->label = 'Install Bundled Plugin (server-side)';
57 $this->description = 'Install and optionally activate a wp.org plugin slug. Internal builder-side replacement for `wp plugin install <slug> --activate` / `wp plugin activate <slug>` — those wp-cli paths route to browser-proxied js_hook tools which cannot run from a Laravel server-to-server call.';
58 // Caller must hold BOTH caps. `install_plugins` is checked again
59 // inside `Plugin_Upgrader::install()` by core; we re-check here so
60 // any future direct caller still gets the same gate.
61 $this->capability = 'install_plugins';
62
63 $this->meta['visibility'] = 'internal';
64 }
65
66 /**
67 * Returns the tool-type classification for this ability.
68 *
69 * @return string One of the Tool_Types constants.
70 */
71 public function get_tool_type() {
72 return Tool_Types::ACTION;
73 }
74
75 /**
76 * Returns the JSON Schema for this ability's input arguments.
77 *
78 * @return array<string,mixed> JSON Schema describing accepted arguments.
79 */
80 public function get_input_schema() {
81 return array(
82 'type' => 'object',
83 'required' => array( 'slug' ),
84 'additionalProperties' => false,
85 'properties' => array(
86 'slug' => array(
87 'type' => 'string',
88 'description' => 'wp.org plugin slug (lower-case alphanumerics and hyphens, e.g. "contact-form-7"). NOT a `folder/file.php` shape.',
89 'pattern' => '^[a-z0-9][a-z0-9-]{0,62}$',
90 ),
91 'activate' => array(
92 'type' => 'boolean',
93 'description' => 'Activate after install. Default true. If the plugin is already installed but inactive, activation still runs.',
94 'default' => true,
95 ),
96 ),
97 );
98 }
99
100 /**
101 * Installs (if missing) and optionally activates a wp.org plugin slug.
102 *
103 * @param array<string,mixed> $args Validated input arguments.
104 * @return array<string,mixed> Standardized success or error response.
105 */
106 public function execute( $args ) {
107 // Validate the RAW input against the slug regex first — `sanitize_key`
108 // silently strips slashes / dots / spaces which would otherwise let
109 // shapes like "foo/bar.php" pass and then fail later at wp.org lookup
110 // with a misleading "Plugin not found" message.
111 $raw_slug = isset( $args['slug'] ) && is_string( $args['slug'] ) ? $args['slug'] : '';
112 $activate = isset( $args['activate'] ) ? (bool) $args['activate'] : true;
113
114 if ( '' === $raw_slug || ! preg_match( '/^[a-z0-9][a-z0-9-]{0,62}$/', $raw_slug ) ) {
115 return self::coded_error( 'Invalid slug — expected wp.org plugin slug (a–z, 0–9, hyphens).', 'invalid_slug' );
116 }
117 $slug = sanitize_key( $raw_slug );
118
119 // File-mods gate first — if file mods are off, capability state is moot.
120 if ( ! wp_is_file_mod_allowed( 'zipai_install_bundled_plugin' ) ) {
121 return self::coded_error( 'File modifications are disabled on this site (DISALLOW_FILE_MODS or the file_mod_allowed filter).', 'file_mods_disabled' );
122 }
123
124 // Capability double-check — defense-in-depth on top of the Abilities
125 // API `permission_callback`.
126 if ( ! current_user_can( 'install_plugins' ) ) {
127 return self::coded_error( 'You do not have install_plugins capability.', 'insufficient_capability' );
128 }
129 if ( $activate && ! current_user_can( 'activate_plugins' ) ) {
130 return self::coded_error( 'You do not have activate_plugins capability.', 'insufficient_capability' );
131 }
132
133 // Network-admin gate — installing into a multisite from a sub-site is
134 // surprising; restrict to network admins.
135 if ( is_multisite() && ! is_super_admin() ) {
136 return self::coded_error( 'On multisite, only network super-admins may install plugins.', 'multisite_super_admin_required' );
137 }
138
139 // Bring in admin-only loaders.
140 if ( ! function_exists( 'get_plugins' ) ) {
141 require_once ABSPATH . 'wp-admin/includes/plugin.php';
142 }
143 if ( ! function_exists( 'plugins_api' ) ) {
144 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
145 }
146 if ( ! class_exists( 'Plugin_Upgrader' ) ) {
147 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
148 }
149 if ( ! class_exists( 'WP_Ajax_Upgrader_Skin' ) ) {
150 require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
151 }
152 // Plugin_Upgrader may call request_filesystem_credentials() through
153 // WP_Upgrader internals. Load file.php explicitly in non-admin
154 // contexts to avoid fatal "undefined function" during server-side
155 // installs invoked server-to-server.
156 if ( ! function_exists( 'request_filesystem_credentials' ) ) {
157 require_once ABSPATH . 'wp-admin/includes/file.php';
158 }
159
160 // Fast path: already installed → skip install, jump to activate.
161 $existing_file = Helper::find_plugin_file_for_slug( $slug );
162
163 if ( null === $existing_file ) {
164 // Not installed — fetch package URL + run install.
165 $information = plugins_api(
166 'plugin_information',
167 array(
168 'slug' => $slug,
169 'fields' => array( 'sections' => false ),
170 )
171 );
172 if ( is_wp_error( $information ) ) {
173 return self::coded_error( 'wp.org lookup failed: ' . $information->get_error_message(), 'wporg_lookup_failed' );
174 }
175 $package = is_object( $information ) && isset( $information->download_link ) && is_string( $information->download_link ) ? $information->download_link : '';
176 if ( '' === $package ) {
177 return self::coded_error( sprintf( 'wp.org returned no download link for "%s".', $slug ), 'wporg_missing_download_link' );
178 }
179
180 // Hard-pin the download host, same as the system/ install
181 // abilities: the API response rides plain-parseable JSON, and the
182 // upgrader runs whatever archive URL it is handed.
183 $download_host = wp_parse_url( $package, PHP_URL_HOST );
184 if ( 'downloads.wordpress.org' !== $download_host ) {
185 return self::coded_error(
186 sprintf( 'Refusing to install "%s": download host "%s" is not downloads.wordpress.org.', $slug, is_string( $download_host ) ? $download_host : '' ),
187 'untrusted_download_host'
188 );
189 }
190
191 $upgrader = new \Plugin_Upgrader( new \WP_Ajax_Upgrader_Skin() );
192 $result = $upgrader->install( $package );
193 if ( is_wp_error( $result ) ) {
194 return self::coded_error( 'install failed: ' . $result->get_error_message(), 'install_failed' );
195 }
196 if ( ! $result ) {
197 return self::coded_error( 'install failed (unknown error).', 'install_failed' );
198 }
199 $existing_file = $upgrader->plugin_info();
200 if ( ! $existing_file ) {
201 $existing_file = Helper::find_plugin_file_for_slug( $slug );
202 }
203 if ( ! $existing_file ) {
204 return self::coded_error( 'install succeeded but plugin file could not be resolved.', 'plugin_file_unresolved' );
205 }
206 }
207
208 $was_active = is_plugin_active( $existing_file ) || is_plugin_active_for_network( $existing_file );
209 if ( $activate && ! $was_active ) {
210 $activation = activate_plugin( $existing_file );
211 if ( is_wp_error( $activation ) ) {
212 return self::coded_error( 'activation failed: ' . $activation->get_error_message(), 'activation_failed' );
213 }
214 // `activate_plugin()` returns null on success OR when an
215 // `update_option(active_plugins)` filter silently rejected the
216 // write. Verify post-state so silent-failure surfaces as a real
217 // error instead of a false success. Read the option stores directly
218 // (equivalent to is_plugin_active/is_plugin_active_for_network) so the
219 // re-check reflects the post-activation runtime state.
220 $active_plugins = (array) get_option( 'active_plugins', array() );
221 $network_plugins = is_multisite() ? (array) get_site_option( 'active_sitewide_plugins', array() ) : array();
222 $still_inactive = ! in_array( $existing_file, $active_plugins, true ) && ! isset( $network_plugins[ $existing_file ] );
223 if ( $still_inactive ) {
224 return self::coded_error(
225 sprintf( 'activate_plugin() returned no error for "%s" but plugin is still inactive — likely an `update_option(active_plugins)` filter rejected the write.', $slug ),
226 'activation_rejected'
227 );
228 }
229 }
230
231 $toggle_result = Plugin_Abilities_Toggler::enable_for_slug( $slug );
232
233 // Read the version off the plugin header rather than from get_plugins(),
234 // whose cache can predate an install this call just ran. Callers gate
235 // features on a minimum version, so a stale reading would gate on the
236 // wrong number. Additive field — the rest of the payload is unchanged.
237 $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $existing_file, false, false );
238 $version = $plugin_data['Version'];
239
240 return Response::success(
241 array(
242 'slug' => $slug,
243 'file' => $existing_file,
244 'version' => $version,
245 'active' => $activate ? ( is_plugin_active( $existing_file ) || is_plugin_active_for_network( $existing_file ) ) : $was_active,
246 'toggles' => $toggle_result,
247 'message' => $activate
248 ? sprintf( 'Plugin "%s" installed and active.', $slug )
249 : sprintf( 'Plugin "%s" installed.', $slug ),
250 )
251 );
252 }
253
254 /**
255 * Returns the JSON Schema for this ability's response.
256 *
257 * @return array<string,mixed> JSON Schema describing the response shape.
258 */
259 public function get_output_schema() {
260 return array(
261 'type' => 'object',
262 'properties' => array(
263 'success' => array( 'type' => 'boolean' ),
264 'message' => array( 'type' => 'object' ),
265 ),
266 );
267 }
268
269 /**
270 * Error payload with machine-readable code for callers.
271 *
272 * @param string $message Human-readable error.
273 * @param string $code Stable error code.
274 * @return array<string,mixed>
275 */
276 private static function coded_error( string $message, string $code ): array {
277 return array(
278 'success' => false,
279 'error' => $message,
280 'error_code' => $code,
281 );
282 }
283 }
284