PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / trunk
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback vtrunk
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / doit / abilities / class-avcf-abilities-wp-cli.php
atarim-visual-collaboration / doit / abilities Last commit date
class-avcf-abilities-base.php 3 weeks ago class-avcf-abilities-block-navigation.php 3 weeks ago class-avcf-abilities-cache.php 3 weeks ago class-avcf-abilities-content.php 1 week ago class-avcf-abilities-core.php 3 days ago class-avcf-abilities-execute-php.php 2 weeks ago class-avcf-abilities-global-styles.php 3 weeks ago class-avcf-abilities-gutenberg.php 2 weeks ago class-avcf-abilities-media.php 1 week ago class-avcf-abilities-metadata.php 1 week ago class-avcf-abilities-navigation.php 3 weeks ago class-avcf-abilities-patterns.php 3 weeks ago class-avcf-abilities-plugins.php 3 days ago class-avcf-abilities-readonly.php 2 weeks ago class-avcf-abilities-settings.php 3 weeks ago class-avcf-abilities-taxonomies.php 3 weeks ago class-avcf-abilities-templates.php 3 weeks ago class-avcf-abilities-theme-files.php 2 weeks ago class-avcf-abilities-themes.php 3 days ago class-avcf-abilities-users.php 3 weeks ago class-avcf-abilities-wp-cli.php 3 days ago
class-avcf-abilities-wp-cli.php
411 lines
1 <?php
2 /**
3 * WP-CLI escape hatch.
4 *
5 * Runs the site's `wp` binary for actions that named abilities can't do or that
6 * are too large / long-running for a single web request (bulk search-replace,
7 * db export/import, regenerate-thumbnails, cron runs, etc). Two abilities:
8 *
9 * - atarim/run-wp-cli : run a `wp` command, synchronously (returns
10 * stdout/stderr/exit_code) or asynchronously in the
11 * background (returns a job_id to poll).
12 * - atarim/get-wp-cli-job : poll a background job's status + output log.
13 *
14 * Capability parity with Novamira's run-wp-cli: arbitrary `wp` commands, no
15 * command allow-list. Guardrails (Atarim conventions):
16 * - Requires `manage_options` (administrator).
17 * - Requires PHP process functions (proc_open/exec); refuses cleanly if the
18 * host disables them or the `wp` binary is absent — so it degrades to
19 * "unavailable", never a fatal.
20 * - Every invocation is audit-logged (user, time, command hash + preview) via
21 * error_log and the `avcf_wp_cli_audit` action.
22 * - Sync runs are bounded by a timeout (default 58s) and the process is
23 * terminated if exceeded — use async for anything longer.
24 * - Background job logs are written to a protected dir under uploads
25 * (.htaccess deny + index.php), since output can contain sensitive data.
26 *
27 * This is the most powerful ability in the catalog (arbitrary `wp`, marked
28 * destructive). Consent is handled agent-side, as with execute-php.
29 *
30 * @package atarim-visual-collaboration
31 */
32
33 if ( ! defined( 'ABSPATH' ) ) {
34 exit;
35 }
36
37 class AVCF_Abilities_WP_CLI {
38
39 /** Default seconds a synchronous command may run before it is terminated. */
40 const SYNC_TIMEOUT = 58;
41 /** Hard ceiling on the sync timeout a caller can request. */
42 const MAX_SYNC_TIMEOUT = 300;
43 /** Default max bytes of a job log returned by get-wp-cli-job. */
44 const DEFAULT_LOG_LIMIT = 1048576; // 1 MB
45
46 public function register() {
47 if ( ! function_exists( 'wp_register_ability' ) ) {
48 return;
49 }
50 $this->register_run();
51 $this->register_get_job();
52 }
53
54 private function register_run() {
55 $self = $this;
56 wp_register_ability( 'atarim/run-wp-cli', [
57 'label' => 'Run WP-CLI Command',
58 'description' => 'Run a WP-CLI command on the server via the site\'s `wp` binary — for actions that no named ability covers, or that are too large/slow for one web request (e.g. wp search-replace, wp db export, wp media regenerate, wp cron event run). Pass args as an array WITHOUT the leading "wp" (e.g. ["plugin","list","--format=json"]). Runs synchronously by default (returns stdout/stderr/exit_code); set async:true for long-running commands (returns a job_id — poll get-wp-cli-job for output). Powerful and DESTRUCTIVE: it can run any wp command, including database and file changes. Requires the wp binary and PHP process execution; if the host disables those it returns a clear "unavailable" message. Every call is audit-logged.',
59 'category' => 'atarim',
60 'input_schema' => [
61 'type' => 'object',
62 'properties' => [
63 'args' => [ 'type' => 'array', 'minItems' => 1, 'items' => [ 'type' => 'string' ], 'description' => 'Arguments passed to `wp`, without the leading "wp". Example: ["option","get","siteurl"].' ],
64 'async' => [ 'type' => 'boolean', 'default' => false, 'description' => 'Run in the background and return a job_id to poll (use for long-running commands).' ],
65 'timeout' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_SYNC_TIMEOUT, 'description' => 'Synchronous-run timeout in seconds (default ' . self::SYNC_TIMEOUT . ', max ' . self::MAX_SYNC_TIMEOUT . '). Ignored when async is true.' ],
66 ],
67 'required' => [ 'args' ],
68 'additionalProperties' => false,
69 ],
70 'output_schema' => [
71 'type' => 'object',
72 'properties' => [
73 'success' => [ 'type' => 'boolean' ],
74 'exit_code' => [ 'type' => 'integer' ],
75 'stdout' => [ 'type' => 'string' ],
76 'stderr' => [ 'type' => 'string' ],
77 'job_id' => [ 'type' => 'string' ],
78 'pid' => [ 'type' => 'integer' ],
79 'message' => [ 'type' => 'string' ],
80 ],
81 'required' => [ 'success', 'message' ],
82 ],
83 'execute_callback' => function( $input = [] ) use ( $self ) { return $self->run( (array) $input ); },
84 'permission_callback' => function() { return current_user_can( 'manage_options' ); },
85 'meta' => [
86 'mcp' => [ 'public' => true, 'type' => 'tool' ],
87 'annotations' => [ 'readonly' => false, 'destructive' => true, 'idempotent' => false ],
88 ],
89 ] );
90 }
91
92 private function register_get_job() {
93 $self = $this;
94 wp_register_ability( 'atarim/get-wp-cli-job', [
95 'label' => 'Get WP-CLI Job Status',
96 'description' => 'Check an asynchronous WP-CLI job started by run-wp-cli with async:true. Returns status ("running" | "completed" | "not_found"), the exit_code once finished, and the captured output log. Use offset/limit to page through a large log (limit -1 returns the whole file).',
97 'category' => 'atarim',
98 'input_schema' => [
99 'type' => 'object',
100 'properties' => [
101 'job_id' => [ 'type' => 'string', 'minLength' => 1, 'description' => 'The job_id returned by run-wp-cli.' ],
102 'offset' => [ 'type' => 'integer', 'minimum' => 0, 'default' => 0, 'description' => 'Byte offset to start reading the log from.' ],
103 'limit' => [ 'type' => 'integer', 'description' => 'Max bytes of log to return (default 1 MB; -1 for the whole file).' ],
104 ],
105 'required' => [ 'job_id' ],
106 'additionalProperties' => false,
107 ],
108 'output_schema' => [
109 'type' => 'object',
110 'properties' => [
111 'success' => [ 'type' => 'boolean' ],
112 'job_id' => [ 'type' => 'string' ],
113 'status' => [ 'type' => 'string' ],
114 'exit_code' => [ 'type' => 'integer' ],
115 'stdout' => [ 'type' => 'string' ],
116 'bytes_read' => [ 'type' => 'integer' ],
117 'truncated' => [ 'type' => 'boolean' ],
118 'message' => [ 'type' => 'string' ],
119 ],
120 'required' => [ 'success', 'job_id', 'status', 'message' ],
121 ],
122 'execute_callback' => function( $input = [] ) use ( $self ) { return $self->get_job( (array) $input ); },
123 'permission_callback' => function() { return current_user_can( 'manage_options' ); },
124 'meta' => [
125 'mcp' => [ 'public' => true, 'type' => 'tool' ],
126 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ],
127 ],
128 ] );
129 }
130
131 /** Main run entry: validate, guard host, resolve wp, audit, dispatch. */
132 public function run( $input ) {
133 $raw = isset( $input['args'] ) && is_array( $input['args'] ) ? $input['args'] : [];
134 $args = [];
135 foreach ( $raw as $arg ) {
136 if ( ! is_string( $arg ) ) {
137 return [ 'success' => false, 'message' => 'Every entry in args must be a string.' ];
138 }
139 $args[] = $arg;
140 }
141 if ( empty( $args ) ) {
142 return [ 'success' => false, 'message' => 'args is required (the wp command as an array, e.g. ["plugin","list"]).' ];
143 }
144 if ( ! function_exists( 'proc_open' ) || ! function_exists( 'exec' ) ) {
145 return [ 'success' => false, 'message' => 'WP-CLI is unavailable: PHP process execution (proc_open/exec) is disabled on this host. This is a server configuration limit — an administrator would need to enable it, or run the command manually.' ];
146 }
147 $wp_path = $this->find_wp_path();
148 if ( null === $wp_path ) {
149 return [ 'success' => false, 'message' => 'WP-CLI is unavailable: the `wp` binary was not found on this server. Install WP-CLI or run the command manually.' ];
150 }
151 // Run as root needs --allow-root, or wp refuses.
152 if ( $this->is_root() && ! in_array( '--allow-root', $args, true ) ) {
153 array_unshift( $args, '--allow-root' );
154 }
155
156 $async = ! empty( $input['async'] );
157 $this->audit( $args, $async );
158
159 if ( $async ) {
160 return $this->run_async( $wp_path, $args );
161 }
162 $timeout = isset( $input['timeout'] ) ? (int) $input['timeout'] : self::SYNC_TIMEOUT;
163 $timeout = max( 1, min( self::MAX_SYNC_TIMEOUT, $timeout ) );
164 return $this->run_sync( $wp_path, $args, $timeout );
165 }
166
167 /** Synchronous run via proc_open, bounded by $timeout seconds. */
168 private function run_sync( $wp_path, $args, $timeout ) {
169 $descriptors = [ 0 => [ 'pipe', 'r' ], 1 => [ 'pipe', 'w' ], 2 => [ 'pipe', 'w' ] ];
170 $pipes = [];
171 // Array form (no shell) on PHP 7.4+; escaped string form otherwise.
172 if ( PHP_VERSION_ID >= 70400 ) {
173 $cmd = array_merge( [ $wp_path ], $args );
174 } else {
175 $cmd = escapeshellarg( $wp_path );
176 foreach ( $args as $a ) {
177 $cmd .= ' ' . escapeshellarg( $a );
178 }
179 }
180 $process = proc_open( $cmd, $descriptors, $pipes, ABSPATH );
181 if ( ! is_resource( $process ) ) {
182 return [ 'success' => false, 'exit_code' => -1, 'stdout' => '', 'stderr' => '', 'message' => 'Failed to start the wp process.' ];
183 }
184 if ( isset( $pipes[0] ) && is_resource( $pipes[0] ) ) {
185 fclose( $pipes[0] );
186 }
187 foreach ( [ 1, 2 ] as $i ) {
188 if ( isset( $pipes[ $i ] ) && is_resource( $pipes[ $i ] ) ) {
189 stream_set_blocking( $pipes[ $i ], false );
190 }
191 }
192 $stdout = '';
193 $stderr = '';
194 $deadline = time() + $timeout;
195 $timed_out = false;
196 do {
197 $status = proc_get_status( $process );
198 if ( isset( $pipes[1] ) && is_resource( $pipes[1] ) ) {
199 $stdout .= stream_get_contents( $pipes[1] );
200 }
201 if ( isset( $pipes[2] ) && is_resource( $pipes[2] ) ) {
202 $stderr .= stream_get_contents( $pipes[2] );
203 }
204 if ( ! $status['running'] ) {
205 break;
206 }
207 if ( time() >= $deadline ) {
208 $timed_out = true;
209 proc_terminate( $process, 9 );
210 break;
211 }
212 usleep( 100000 ); // 100ms
213 } while ( true );
214
215 foreach ( [ 1, 2 ] as $i ) {
216 if ( isset( $pipes[ $i ] ) && is_resource( $pipes[ $i ] ) ) {
217 fclose( $pipes[ $i ] );
218 }
219 }
220 $exit_code = proc_close( $process );
221
222 if ( $timed_out ) {
223 return [
224 'success' => false,
225 'exit_code' => -1,
226 'stdout' => $stdout,
227 'stderr' => $stderr,
228 'message' => sprintf( 'Command exceeded the %ds synchronous timeout and was terminated. Re-run with async:true for long-running commands.', $timeout ),
229 ];
230 }
231 return [
232 'success' => ( 0 === $exit_code ),
233 'exit_code' => $exit_code,
234 'stdout' => $stdout,
235 'stderr' => $stderr,
236 'message' => ( 0 === $exit_code ) ? 'Command completed.' : sprintf( 'Command exited with code %d.', $exit_code ),
237 ];
238 }
239
240 /** Background run: nohup the command, capture output+exit to job files. */
241 private function run_async( $wp_path, $args ) {
242 $dir = $this->jobs_dir();
243 if ( null === $dir ) {
244 return [ 'success' => false, 'message' => 'Could not create the job directory for background execution.' ];
245 }
246 $job_id = bin2hex( random_bytes( 8 ) );
247 $log_file = $dir . 'job_' . $job_id . '.log';
248 $status_file = $dir . 'job_' . $job_id . '.status';
249 if ( false === file_put_contents( $log_file, '' ) ) { // phpcs:ignore
250 return [ 'success' => false, 'message' => 'Failed to create the job log file.' ];
251 }
252
253 $cmd_args = array_map( 'escapeshellarg', $args );
254 $wp_cmd = escapeshellarg( $wp_path ) . ' ' . implode( ' ', $cmd_args );
255 $inner = sprintf( 'cd %s && (%s > %s 2>&1; echo $? > %s)', escapeshellarg( ABSPATH ), $wp_cmd, escapeshellarg( $log_file ), escapeshellarg( $status_file ) );
256 $cmd = 'nohup sh -c ' . escapeshellarg( $inner ) . ' > /dev/null 2>&1 & echo $!';
257
258 $out = [];
259 $rc = 0;
260 exec( $cmd, $out, $rc ); // phpcs:ignore
261 $pid = ( 0 === $rc && isset( $out[0] ) && '' !== trim( $out[0] ) ) ? (int) trim( $out[0] ) : null;
262 if ( 0 !== $rc || null === $pid ) {
263 file_put_contents( $status_file, '127' ); // phpcs:ignore
264 return [ 'success' => false, 'job_id' => $job_id, 'message' => 'Failed to start the background wp process.' ];
265 }
266 return [
267 'success' => true,
268 'job_id' => $job_id,
269 'pid' => $pid,
270 'message' => sprintf( 'Started background job %s (pid %d). Poll get-wp-cli-job for status and output.', $job_id, $pid ),
271 ];
272 }
273
274 /** Poll a background job. */
275 public function get_job( $input ) {
276 $job_id = isset( $input['job_id'] ) ? (string) $input['job_id'] : '';
277 if ( ! preg_match( '/^[a-f0-9]{16}$/i', $job_id ) ) {
278 return [ 'success' => false, 'job_id' => $job_id, 'status' => 'not_found', 'message' => 'Invalid job_id format.' ];
279 }
280 $dir = $this->jobs_dir( false );
281 $log_file = $dir . 'job_' . $job_id . '.log';
282 $status_file = $dir . 'job_' . $job_id . '.status';
283 if ( ! is_file( $log_file ) ) {
284 return [ 'success' => false, 'job_id' => $job_id, 'status' => 'not_found', 'message' => 'No job with that id (it may have expired or never existed).' ];
285 }
286 $status = 'running';
287 $exit_code = null;
288 if ( is_file( $status_file ) ) {
289 $status = 'completed';
290 $content = trim( (string) file_get_contents( $status_file ) );
291 if ( '' !== $content ) {
292 $exit_code = (int) $content;
293 }
294 }
295 $offset = isset( $input['offset'] ) ? max( 0, (int) $input['offset'] ) : 0;
296 $limit = isset( $input['limit'] ) ? (int) $input['limit'] : self::DEFAULT_LOG_LIMIT;
297 $slice = $this->read_log_slice( $log_file, $offset, $limit );
298
299 $res = [
300 'success' => true,
301 'job_id' => $job_id,
302 'status' => $status,
303 'stdout' => $slice['content'],
304 'bytes_read' => $slice['bytes_read'],
305 'truncated' => $slice['truncated'],
306 'message' => ( 'completed' === $status ) ? sprintf( 'Job completed (exit %s).', ( null === $exit_code ? '?' : $exit_code ) ) : 'Job still running.',
307 ];
308 if ( null !== $exit_code ) {
309 $res['exit_code'] = $exit_code;
310 }
311 return $res;
312 }
313
314 /* ------------------------------------------------------------------ */
315
316 /** Locate the `wp` binary. Returns absolute path or null. */
317 private function find_wp_path() {
318 if ( function_exists( 'exec' ) ) {
319 foreach ( [ 'which wp 2>/dev/null', 'command -v wp 2>/dev/null' ] as $probe ) {
320 $out = [];
321 $rc = 0;
322 exec( $probe, $out, $rc ); // phpcs:ignore
323 if ( 0 === $rc && isset( $out[0] ) && '' !== trim( $out[0] ) ) {
324 return trim( $out[0] );
325 }
326 }
327 }
328 foreach ( [ '/usr/local/bin/wp', '/usr/bin/wp', '/bin/wp', '/usr/local/sbin/wp', '/usr/sbin/wp' ] as $p ) {
329 if ( is_file( $p ) && is_executable( $p ) ) {
330 return $p;
331 }
332 }
333 return null;
334 }
335
336 /** Whether the PHP process is running as root. */
337 private function is_root() {
338 if ( function_exists( 'posix_geteuid' ) ) {
339 return 0 === posix_geteuid();
340 }
341 if ( function_exists( 'exec' ) ) {
342 $out = [];
343 $rc = 0;
344 exec( 'id -u 2>/dev/null', $out, $rc ); // phpcs:ignore
345 if ( 0 === $rc && isset( $out[0] ) ) {
346 return '0' === trim( $out[0] );
347 }
348 }
349 return false;
350 }
351
352 /** Protected jobs directory under uploads. Returns path (with trailing slash) or null. */
353 private function jobs_dir( $ensure = true ) {
354 $up = wp_upload_dir();
355 if ( ! empty( $up['error'] ) || empty( $up['basedir'] ) ) {
356 return null;
357 }
358 $dir = trailingslashit( $up['basedir'] ) . 'atarim-wpcli-jobs/';
359 if ( $ensure && ! is_dir( $dir ) ) {
360 wp_mkdir_p( $dir );
361 // Harden: job logs can contain sensitive output — block web access.
362 @file_put_contents( $dir . '.htaccess', "Require all denied\n<IfModule !mod_authz_core.c>\nDeny from all\n</IfModule>\n" ); // phpcs:ignore
363 @file_put_contents( $dir . 'index.php', "<?php // Silence is golden.\n" ); // phpcs:ignore
364 }
365 return $dir;
366 }
367
368 /** Read a byte slice of a log file. Returns [ content, bytes_read, truncated ]. */
369 private function read_log_slice( $log_file, $offset, $limit ) {
370 $size = (int) @filesize( $log_file ); // phpcs:ignore
371 if ( $offset >= $size ) {
372 return [ 'content' => '', 'bytes_read' => 0, 'truncated' => false ];
373 }
374 $handle = fopen( $log_file, 'rb' ); // phpcs:ignore
375 if ( false === $handle ) {
376 return [ 'content' => '', 'bytes_read' => 0, 'truncated' => false ];
377 }
378 if ( $offset > 0 ) {
379 fseek( $handle, $offset );
380 }
381 $read_length = ( -1 === $limit ) ? ( $size - $offset ) : $limit;
382 $content = fread( $handle, max( 1, (int) $read_length ) );
383 fclose( $handle );
384 if ( false === $content ) {
385 return [ 'content' => '', 'bytes_read' => 0, 'truncated' => false ];
386 }
387 $bytes_read = strlen( $content );
388 $truncated = ( -1 !== $limit ) && ( ( $offset + $bytes_read ) < $size );
389 return [ 'content' => $content, 'bytes_read' => $bytes_read, 'truncated' => $truncated ];
390 }
391
392 /** Audit every invocation (user, time, command hash + preview). */
393 public function audit( $args, $async ) {
394 $user = function_exists( 'wp_get_current_user' ) ? wp_get_current_user() : null;
395 $joined = implode( ' ', $args );
396 $entry = [
397 'time' => gmdate( 'c' ),
398 'user_id' => ( $user && isset( $user->ID ) ) ? (int) $user->ID : 0,
399 'login' => ( $user && isset( $user->user_login ) ) ? (string) $user->user_login : '',
400 'async' => (bool) $async,
401 'sha1' => sha1( $joined ),
402 'preview' => substr( $joined, 0, 200 ),
403 ];
404 error_log( sprintf( // phpcs:ignore
405 '[atarim/run-wp-cli] user=%d(%s) async=%s sha1=%s cmd=%s',
406 $entry['user_id'], $entry['login'], $entry['async'] ? '1' : '0', $entry['sha1'], $entry['preview']
407 ) );
408 do_action( 'avcf_wp_cli_audit', $entry );
409 }
410 }
411