PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 0.8.6 All 33 releases
desktop-mode / includes / agents / abilities.php

abilities.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.8, at includes/agents/abilities.php

613 lines 21.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Agents: abilities bridge.
4 *
5 * Two halves:
6 *
7 * 1. Registers the agent-oriented abilities against Core's Abilities
8 * API: `desktop-mode/get-post` and `desktop-mode/get-media`
9 * (read-only) plus the mutating trio `desktop-mode/update-post`,
10 * `desktop-mode/update-media` (alt text / title / caption /
11 * description), and `desktop-mode/create-post` (draft-only). The
12 * `openstation` category ships from the AI Copilot module
13 * (always loaded), so this file only adds abilities to it. The
14 * read abilities carry the `readonly` annotation and therefore
15 * also become available to the AI Copilot assistant; the mutating
16 * ones do not — they are reachable only through an agent whose
17 * allowlist includes them.
18 *
19 * 2. Provides the abilities catalogue the picker UI consumes: every
20 * ability registered on the site, projected to
21 * `{ slug, label, description, category, readonly }`. Unlike the
22 * Copilot (which advertises only read-only abilities), agents may
23 * be granted mutating abilities — that is the point. The
24 * compensating controls are the explicit per-agent allowlist set by
25 * an `edit_users` human, the agent's role, and each ability's own
26 * `permission_callback` evaluated against the agent user.
27 *
28 * @package OpenStation
29 */
30
31 defined( 'ABSPATH' ) || exit;
32
33 /**
34 * Registers the agent-oriented abilities.
35 *
36 * @return void
37 */
38 function openstation_agents_register_abilities() {
39 if ( ! function_exists( 'wp_register_ability' ) ) {
40 return;
41 }
42
43 wp_register_ability(
44 'desktop-mode/get-post',
45 array(
46 'label' => __( 'Get post by id', 'desktop-mode' ),
47 // The rawness of `content` is load-bearing for any agent that
48 // edits posts, and it belongs here rather than in a prompt:
49 // stated once on the ability, every agent's generated tool
50 // manifest carries it. Saying it only in an agent's own
51 // instructions leaves every other agent guessing, and a
52 // cautious one will refuse to write rather than risk
53 // flattening blocks.
54 'description' => 'Return a post — title, content, excerpt, status, author, dates — by its numeric id. `content` is the RAW stored content exactly as saved, with block delimiter comments (`<!-- wp:… -->`) intact; it is never rendered output, so it is safe to edit and write back. Honours the caller\'s read capability.',
55 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
56 'input_schema' => array(
57 'type' => 'object',
58 'additionalProperties' => false,
59 'required' => array( 'post_id' ),
60 'properties' => array(
61 'post_id' => array(
62 'type' => 'integer',
63 'description' => 'The post id to fetch.',
64 ),
65 ),
66 ),
67 'output_schema' => openstation_ai_ability_output_schema(
68 array(
69 'id' => array( 'type' => 'integer' ),
70 'title' => array( 'type' => 'string' ),
71 'content' => array( 'type' => 'string' ),
72 'status' => array( 'type' => 'string' ),
73 )
74 ),
75 'execute_callback' => 'openstation_agents_ability_get_post',
76 'permission_callback' => 'openstation_agents_ability_get_post_can',
77 'meta' => array(
78 'annotations' => array(
79 'readonly' => true,
80 'idempotent' => true,
81 ),
82 'show_in_rest' => true,
83 ),
84 )
85 );
86
87 wp_register_ability(
88 'desktop-mode/get-media',
89 array(
90 'label' => __( 'Get media details', 'desktop-mode' ),
91 'description' => 'Return details for a media library item (attachment) by numeric id: file URL, mime type, dimensions, alt text, caption, and the post it is attached to. Use this to read images or other media referenced by posts.',
92 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
93 'input_schema' => array(
94 'type' => 'object',
95 'additionalProperties' => false,
96 'required' => array( 'attachment_id' ),
97 'properties' => array(
98 'attachment_id' => array(
99 'type' => 'integer',
100 'description' => 'The attachment (media library) id.',
101 ),
102 ),
103 ),
104 'output_schema' => openstation_ai_ability_output_schema(
105 array(
106 'id' => array( 'type' => 'integer' ),
107 'url' => array( 'type' => 'string' ),
108 'mime' => array( 'type' => 'string' ),
109 )
110 ),
111 'execute_callback' => 'openstation_agents_ability_get_media',
112 'permission_callback' => 'openstation_agents_ability_get_media_can',
113 'meta' => array(
114 'annotations' => array(
115 'readonly' => true,
116 'idempotent' => true,
117 ),
118 'show_in_rest' => true,
119 ),
120 )
121 );
122
123 wp_register_ability(
124 'desktop-mode/update-media',
125 array(
126 'label' => __( 'Update media details', 'desktop-mode' ),
127 'description' => 'Update metadata on a media library item (attachment): alt text, title, caption, and/or description. The file itself is never touched. Honours the edit capability on the attachment.',
128 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
129 'input_schema' => array(
130 'type' => 'object',
131 'additionalProperties' => false,
132 'required' => array( 'attachment_id' ),
133 'properties' => array(
134 'attachment_id' => array(
135 'type' => 'integer',
136 'description' => 'The attachment (media library) id.',
137 ),
138 'alt_text' => array(
139 'type' => 'string',
140 'description' => 'New alternative text for the image (plain text, describing what the image shows).',
141 ),
142 'title' => array(
143 'type' => 'string',
144 'description' => 'New attachment title.',
145 ),
146 'caption' => array(
147 'type' => 'string',
148 'description' => 'New caption.',
149 ),
150 'description' => array(
151 'type' => 'string',
152 'description' => 'New description.',
153 ),
154 ),
155 ),
156 'output_schema' => openstation_ai_ability_output_schema(
157 array(
158 'id' => array( 'type' => 'integer' ),
159 'updated' => array( 'type' => 'boolean' ),
160 )
161 ),
162 'execute_callback' => 'openstation_agents_ability_update_media',
163 'permission_callback' => 'openstation_agents_ability_update_media_can',
164 'meta' => array(
165 'show_in_rest' => true,
166 ),
167 )
168 );
169
170 wp_register_ability(
171 'desktop-mode/create-post',
172 array(
173 'label' => __( 'Create draft post', 'desktop-mode' ),
174 'description' => 'Create a NEW post or page as a DRAFT, authored by the calling user. The status is always draft: this ability can never publish. Use it to produce reviewable content (translations, variants, generated drafts) without touching any existing post. `content` is stored RAW, exactly as passed, so send block markup with its delimiter comments (`<!-- wp:… -->`) intact.',
175 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
176 'input_schema' => array(
177 'type' => 'object',
178 'additionalProperties' => false,
179 'required' => array( 'title', 'content' ),
180 'properties' => array(
181 'title' => array(
182 'type' => 'string',
183 'description' => 'Post title.',
184 ),
185 'content' => array(
186 'type' => 'string',
187 'description' => 'Post content (HTML / block markup).',
188 ),
189 'excerpt' => array(
190 'type' => 'string',
191 'description' => 'Optional excerpt.',
192 ),
193 'type' => array(
194 'type' => 'string',
195 'enum' => array( 'post', 'page' ),
196 'description' => 'Post type. Defaults to post.',
197 ),
198 ),
199 ),
200 'output_schema' => openstation_ai_ability_output_schema(
201 array(
202 'id' => array( 'type' => 'integer' ),
203 'status' => array( 'type' => 'string' ),
204 )
205 ),
206 'execute_callback' => 'openstation_agents_ability_create_post',
207 'permission_callback' => 'openstation_agents_ability_create_post_can',
208 'meta' => array(
209 'show_in_rest' => true,
210 ),
211 )
212 );
213
214 wp_register_ability(
215 'desktop-mode/update-post',
216 array(
217 'label' => __( 'Update post', 'desktop-mode' ),
218 'description' => 'Update fields on an existing post. Accepts any subset of title / content / excerpt / status. `content` is stored RAW, exactly as passed, so send block markup with its delimiter comments (`<!-- wp:… -->`) intact — passing rendered HTML would flatten the post\'s blocks. Honours the edit_post capability of the calling user.',
219 'category' => OPENSTATION_AI_ABILITY_CATEGORY,
220 'input_schema' => array(
221 'type' => 'object',
222 'additionalProperties' => false,
223 'required' => array( 'post_id' ),
224 'properties' => array(
225 'post_id' => array(
226 'type' => 'integer',
227 'description' => 'The post id to update.',
228 ),
229 'title' => array(
230 'type' => 'string',
231 'description' => 'New post title.',
232 ),
233 'content' => array(
234 'type' => 'string',
235 'description' => 'New post content (HTML / block markup).',
236 ),
237 'excerpt' => array(
238 'type' => 'string',
239 'description' => 'New post excerpt.',
240 ),
241 'status' => array(
242 'type' => 'string',
243 'enum' => array( 'publish', 'draft', 'pending', 'private' ),
244 'description' => 'New post status.',
245 ),
246 ),
247 ),
248 'output_schema' => openstation_ai_ability_output_schema(
249 array(
250 'id' => array( 'type' => 'integer' ),
251 'updated' => array( 'type' => 'boolean' ),
252 )
253 ),
254 'execute_callback' => 'openstation_agents_ability_update_post',
255 'permission_callback' => 'openstation_agents_ability_update_post_can',
256 'meta' => array(
257 'show_in_rest' => true,
258 ),
259 )
260 );
261 }
262 add_action( 'wp_abilities_api_init', 'openstation_agents_register_abilities' );
263
264 /**
265 * `desktop-mode/get-post` execute callback.
266 *
267 * @param array $args Validated input.
268 * @return array|WP_Error
269 */
270 function openstation_agents_ability_get_post( $args ) {
271 $args = (array) $args;
272 $post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
273 $post = $post_id > 0 ? get_post( $post_id ) : null;
274 if ( ! ( $post instanceof WP_Post ) ) {
275 return new WP_Error( 'openstation_agent_post_not_found', __( 'Post not found.', 'desktop-mode' ) );
276 }
277 return array(
278 'id' => (int) $post->ID,
279 'title' => (string) $post->post_title,
280 'content' => (string) $post->post_content,
281 'excerpt' => (string) $post->post_excerpt,
282 'status' => (string) $post->post_status,
283 'type' => (string) $post->post_type,
284 'author' => (int) $post->post_author,
285 'date' => (string) $post->post_date_gmt,
286 'modified' => (string) $post->post_modified_gmt,
287 'link' => (string) get_permalink( $post ),
288 );
289 }
290
291 /**
292 * `desktop-mode/get-post` permission callback.
293 *
294 * `read_post` decides visibility (published / private / draft) and
295 * never the post password — WordPress splits the two deliberately, so
296 * a plain `read_post` check would hand a Subscriber the raw body of a
297 * password-protected post. Mirror Core: a sealed post stays sealed
298 * unless the caller can edit it (the same escape hatch
299 * `WP_REST_Posts_Controller::check_password_required()` grants), and
300 * because this ability returns RAW `post_content` there is no empty
301 * rendered field to fall back to — the only safe answer is to refuse.
302 *
303 * @param array $args Input args.
304 * @return bool
305 */
306 function openstation_agents_ability_get_post_can( $args ) {
307 $args = (array) $args;
308 $post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
309 if ( $post_id <= 0 ) {
310 return false;
311 }
312 if ( ! current_user_can( 'read_post', $post_id ) ) {
313 return false;
314 }
315 $post = get_post( $post_id );
316 if ( $post instanceof WP_Post && post_password_required( $post ) && ! current_user_can( 'edit_post', $post_id ) ) {
317 return false;
318 }
319 return true;
320 }
321
322 /**
323 * `desktop-mode/get-media` execute callback.
324 *
325 * @param array $args Validated input.
326 * @return array|WP_Error
327 */
328 function openstation_agents_ability_get_media( $args ) {
329 $args = (array) $args;
330 $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0;
331 $post = $attachment_id > 0 ? get_post( $attachment_id ) : null;
332 if ( ! ( $post instanceof WP_Post ) || 'attachment' !== $post->post_type ) {
333 return new WP_Error( 'openstation_agent_media_not_found', __( 'Attachment not found.', 'desktop-mode' ) );
334 }
335
336 $meta = wp_get_attachment_metadata( $attachment_id );
337 if ( ! is_array( $meta ) ) {
338 $meta = array();
339 }
340
341 return array(
342 'id' => (int) $post->ID,
343 'title' => (string) $post->post_title,
344 'url' => (string) wp_get_attachment_url( $attachment_id ),
345 'mime' => (string) get_post_mime_type( $post ),
346 'width' => isset( $meta['width'] ) ? (int) $meta['width'] : null,
347 'height' => isset( $meta['height'] ) ? (int) $meta['height'] : null,
348 'filesize' => isset( $meta['filesize'] ) ? (int) $meta['filesize'] : null,
349 'alt' => (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
350 'caption' => (string) $post->post_excerpt,
351 'date' => (string) $post->post_date_gmt,
352 'attachedTo' => (int) $post->post_parent,
353 );
354 }
355
356 /**
357 * `desktop-mode/get-media` permission callback.
358 *
359 * Gates on `upload_files` (author+), deliberately NOT on `read_post`:
360 * for `inherit`-status attachments that check defers to the parent
361 * post (and effectively requires edit rights when unattached), which
362 * wrongly blocks read-only access to media whose file URL is public
363 * on a standard site anyway.
364 *
365 * @param array $args Input args.
366 * @return bool
367 */
368 function openstation_agents_ability_get_media_can( $args ) {
369 $args = (array) $args;
370 $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0;
371 if ( $attachment_id <= 0 ) {
372 return false;
373 }
374 return current_user_can( 'upload_files' );
375 }
376
377 /**
378 * `desktop-mode/update-media` execute callback.
379 *
380 * @param array $args Validated input.
381 * @return array|WP_Error
382 */
383 function openstation_agents_ability_update_media( $args ) {
384 $args = (array) $args;
385 $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0;
386 $post = $attachment_id > 0 ? get_post( $attachment_id ) : null;
387 if ( ! ( $post instanceof WP_Post ) || 'attachment' !== $post->post_type ) {
388 return new WP_Error( 'openstation_agent_media_not_found', __( 'Attachment not found.', 'desktop-mode' ) );
389 }
390
391 if ( isset( $args['alt_text'] ) ) {
392 update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( (string) $args['alt_text'] ) );
393 }
394
395 $update = array( 'ID' => $attachment_id );
396 if ( isset( $args['title'] ) ) {
397 $update['post_title'] = sanitize_text_field( (string) $args['title'] );
398 }
399 if ( isset( $args['caption'] ) ) {
400 $update['post_excerpt'] = sanitize_text_field( (string) $args['caption'] );
401 }
402 if ( isset( $args['description'] ) ) {
403 $update['post_content'] = wp_kses_post( (string) $args['description'] );
404 }
405 if ( count( $update ) > 1 ) {
406 $result = wp_update_post( $update, true );
407 if ( is_wp_error( $result ) ) {
408 return $result;
409 }
410 }
411
412 return array(
413 'id' => $attachment_id,
414 'updated' => true,
415 );
416 }
417
418 /**
419 * `desktop-mode/update-media` permission callback — the same edit
420 * capability wp-admin requires to change attachment details.
421 *
422 * @param array $args Input args.
423 * @return bool
424 */
425 function openstation_agents_ability_update_media_can( $args ) {
426 $args = (array) $args;
427 $attachment_id = isset( $args['attachment_id'] ) ? (int) $args['attachment_id'] : 0;
428 if ( $attachment_id <= 0 ) {
429 return false;
430 }
431 return current_user_can( 'edit_post', $attachment_id );
432 }
433
434 /**
435 * `desktop-mode/create-post` execute callback. Status is hard-forced
436 * to `draft` — this ability can never publish, whatever the model
437 * asks for.
438 *
439 * @param array $args Validated input.
440 * @return array|WP_Error
441 */
442 function openstation_agents_ability_create_post( $args ) {
443 $args = (array) $args;
444 $type = isset( $args['type'] ) && 'page' === $args['type'] ? 'page' : 'post';
445
446 $post_id = wp_insert_post(
447 array(
448 'post_type' => $type,
449 'post_status' => 'draft',
450 'post_title' => sanitize_text_field( isset( $args['title'] ) ? (string) $args['title'] : '' ),
451 'post_content' => wp_kses_post( isset( $args['content'] ) ? (string) $args['content'] : '' ),
452 'post_excerpt' => sanitize_text_field( isset( $args['excerpt'] ) ? (string) $args['excerpt'] : '' ),
453 'post_author' => get_current_user_id(),
454 ),
455 true
456 );
457 if ( is_wp_error( $post_id ) ) {
458 return $post_id;
459 }
460
461 return array(
462 'id' => (int) $post_id,
463 'type' => $type,
464 'status' => 'draft',
465 'title' => (string) get_the_title( $post_id ),
466 'editLink' => (string) get_edit_post_link( $post_id, 'raw' ),
467 );
468 }
469
470 /**
471 * `desktop-mode/create-post` permission callback.
472 *
473 * @param array $args Input args.
474 * @return bool
475 */
476 function openstation_agents_ability_create_post_can( $args ) {
477 $args = (array) $args;
478 if ( isset( $args['type'] ) && 'page' === $args['type'] ) {
479 return current_user_can( 'edit_pages' );
480 }
481 return current_user_can( 'edit_posts' );
482 }
483
484 /**
485 * `desktop-mode/update-post` execute callback.
486 *
487 * @param array $args Validated input.
488 * @return array|WP_Error
489 */
490 function openstation_agents_ability_update_post( $args ) {
491 $args = (array) $args;
492 $post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
493 if ( $post_id <= 0 || ! get_post( $post_id ) ) {
494 return new WP_Error( 'openstation_agent_post_not_found', __( 'Post not found.', 'desktop-mode' ) );
495 }
496
497 $update = array( 'ID' => $post_id );
498 if ( isset( $args['title'] ) ) {
499 $update['post_title'] = sanitize_text_field( (string) $args['title'] );
500 }
501 if ( isset( $args['content'] ) ) {
502 $update['post_content'] = wp_kses_post( (string) $args['content'] );
503 }
504 if ( isset( $args['excerpt'] ) ) {
505 $update['post_excerpt'] = sanitize_text_field( (string) $args['excerpt'] );
506 }
507 if ( isset( $args['status'] ) ) {
508 $status = sanitize_key( (string) $args['status'] );
509 if ( ! in_array( $status, array( 'publish', 'draft', 'pending', 'private' ), true ) ) {
510 return new WP_Error( 'openstation_agent_invalid_status', __( 'Invalid post status.', 'desktop-mode' ) );
511 }
512 $update['post_status'] = $status;
513 }
514
515 $result = wp_update_post( $update, true );
516 if ( is_wp_error( $result ) ) {
517 return $result;
518 }
519 return array(
520 'id' => (int) $result,
521 'updated' => true,
522 );
523 }
524
525 /**
526 * `desktop-mode/update-post` permission callback.
527 *
528 * Publishing needs `publish_posts` on top of `edit_post` — the same
529 * split wp-admin enforces on a human editor.
530 *
531 * @param array $args Input args.
532 * @return bool
533 */
534 function openstation_agents_ability_update_post_can( $args ) {
535 $args = (array) $args;
536 $post_id = isset( $args['post_id'] ) ? (int) $args['post_id'] : 0;
537 if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) {
538 return false;
539 }
540 if ( isset( $args['status'] ) && 'publish' === $args['status'] && ! current_user_can( 'publish_posts' ) ) {
541 return false;
542 }
543 return true;
544 }
545
546 /**
547 * Catalogue of abilities exposed to the agents picker.
548 *
549 * Primary source: Core's Abilities API (`wp_get_abilities()`) — every
550 * ability the site registered, Core's, this plugin's, or any third
551 * party's, projected into the picker shape with an honest
552 * readonly/mutating badge derived from `meta.annotations.readonly`.
553 *
554 * @return array<int, array{slug:string, label:string, description:string, category:string, readonly:bool}>
555 */
556 function openstation_agents_abilities_catalogue() {
557 $catalogue = array();
558
559 if ( function_exists( 'wp_get_abilities' ) ) {
560 foreach ( wp_get_abilities() as $ability ) {
561 if ( ! $ability instanceof WP_Ability ) {
562 continue;
563 }
564 $meta = (array) $ability->get_meta();
565 $annotations = isset( $meta['annotations'] ) && is_array( $meta['annotations'] ) ? $meta['annotations'] : array();
566
567 $catalogue[] = array(
568 'slug' => (string) $ability->get_name(),
569 'label' => (string) $ability->get_label(),
570 'description' => (string) $ability->get_description(),
571 'category' => (string) $ability->get_category(),
572 'readonly' => ! empty( $annotations['readonly'] ),
573 );
574 }
575 }
576
577 /**
578 * Filter the catalogue of abilities exposed to the agents picker.
579 *
580 * Sites can narrow the pickable set (drop rows) or append
581 * Desktop-Mode-only entries. The preferred extension path stays
582 * `wp_register_ability()` so every agent runtime sees the same
583 * registry.
584 *
585 * @param array $catalogue Abilities projected from `wp_get_abilities()`.
586 */
587 $catalogue = apply_filters( 'openstation_agent_abilities_catalogue', $catalogue );
588 if ( ! is_array( $catalogue ) ) {
589 return array();
590 }
591
592 $seen = array();
593 $out = array();
594 foreach ( $catalogue as $row ) {
595 if ( ! is_array( $row ) || empty( $row['slug'] ) ) {
596 continue;
597 }
598 $slug = sanitize_text_field( (string) $row['slug'] );
599 if ( '' === $slug || isset( $seen[ $slug ] ) ) {
600 continue;
601 }
602 $seen[ $slug ] = true;
603 $out[] = array(
604 'slug' => $slug,
605 'label' => isset( $row['label'] ) && '' !== (string) $row['label'] ? (string) $row['label'] : $slug,
606 'description' => isset( $row['description'] ) ? (string) $row['description'] : '',
607 'category' => isset( $row['category'] ) ? (string) $row['category'] : '',
608 'readonly' => ! empty( $row['readonly'] ),
609 );
610 }
611 return $out;
612 }
613